1.修复创建多个向量库
This commit is contained in:
@@ -46,6 +46,7 @@ public class AiApiConfig {
|
||||
private String list = "/documents/list";
|
||||
private String delete = "/documents";
|
||||
private String chunks = "/documents/{path}/chunks";
|
||||
private String preview = "/documents/{path}/preview";
|
||||
private String deprecate = "/collections/{name}/documents/{filename}/deprecate";
|
||||
private String restore = "/collections/{name}/documents/{filename}/restore";
|
||||
private String versions = "/collections/{name}/documents/{filename}/versions";
|
||||
|
||||
@@ -205,14 +205,12 @@ public class AIChatController {
|
||||
@Parameter(description = "页码,默认1") @RequestParam(defaultValue = "1") Integer page,
|
||||
@Parameter(description = "每页大小,默认20") @RequestParam(defaultValue = "20") Integer pageSize,
|
||||
HttpServletRequest httpRequest) {
|
||||
log.info("📨 收到获取会话详情请求,sessionId={}", sessionId);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
try {
|
||||
Long userId = getCurrentUserId(httpRequest);
|
||||
if (userId == null) {
|
||||
result.put("success", false);
|
||||
result.put("message", "用户未登录");
|
||||
log.warn("用户未登录,拒绝访问");
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -223,21 +221,10 @@ public class AIChatController {
|
||||
if (!isSessionBelongsToUser) {
|
||||
result.put("success", false);
|
||||
result.put("message", "无权访问该会话");
|
||||
log.warn("用户 {} 无权访问会话 {}", userId, sessionId);
|
||||
return result;
|
||||
}
|
||||
|
||||
List<ChatMessage> 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;
|
||||
@@ -253,10 +240,8 @@ public class AIChatController {
|
||||
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);
|
||||
log.error("获取会话详情失败", e);
|
||||
result.put("success", false);
|
||||
result.put("message", "获取会话详情失败: " + e.getMessage());
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package top.tqx.demo_1.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -14,6 +15,7 @@ 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.enums.FileProcessStepStatus;
|
||||
import top.tqx.demo_1.mapper.DepartmentMapper;
|
||||
import top.tqx.demo_1.mapper.FileMapper;
|
||||
import top.tqx.demo_1.mapper.KnowledgeBasePathMapper;
|
||||
@@ -25,6 +27,8 @@ import top.tqx.demo_1.common.Result;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
@@ -385,6 +389,8 @@ public class CollectionController extends BaseController {
|
||||
}
|
||||
}
|
||||
|
||||
// ... existing code ...
|
||||
|
||||
@Operation(summary = "文件向量化", description = "将已上传的文件解析并存储到向量库")
|
||||
@PostMapping("/{name}/vectorize")
|
||||
public Result<Map<String, Object>> vectorizeFile(
|
||||
@@ -441,10 +447,67 @@ public class CollectionController extends BaseController {
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "批量文件向量化", description = "批量将已上传的文件解析并存储到向量库")
|
||||
@PostMapping("/{name}/vectorize/batch")
|
||||
public Result<Map<String, Object>> batchVectorizeFiles(
|
||||
@Parameter(description = "向量库名称", required = true) @PathVariable String name,
|
||||
@Operation(summary = "智能文件向量化", description = "自动判断部门向量库并执行向量化,无需指定向量库名称")
|
||||
@PostMapping("/vectorize/smart")
|
||||
public Result<Map<String, Object>> smartVectorizeFile(
|
||||
@Parameter(description = "文件ID", required = true) @RequestParam Long fileId,
|
||||
HttpServletRequest request) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(request);
|
||||
if (userId == null) {
|
||||
return error("用户未登录");
|
||||
}
|
||||
|
||||
log.info("触发智能文件向量化: fileId={}, userId={}", 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);
|
||||
}
|
||||
|
||||
String collectionName = determineCollectionForFile(file);
|
||||
log.info("自动确定向量库名称: fileId={}, collection={}", fileId, collectionName);
|
||||
|
||||
asyncVectorizeService.updateFileProcessStatus(fileId,
|
||||
AsyncVectorizeService.VECTORIZE_STATUS_VECTORIZING,
|
||||
"正在向量化,请稍后...");
|
||||
|
||||
asyncVectorizeService.vectorizeFileAsync(collectionName, fileId, userId);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", true);
|
||||
result.put("message", "向量化任务已提交,正在后台处理中");
|
||||
result.put("fileId", fileId);
|
||||
result.put("fileName", file.getFileName());
|
||||
result.put("collection", collectionName);
|
||||
result.put("status", AsyncVectorizeService.VECTORIZE_STATUS_VECTORIZING);
|
||||
|
||||
log.info("智能文件向量化任务已提交: collection={}, fileId={}, fileName={}",
|
||||
collectionName, fileId, file.getFileName());
|
||||
|
||||
return success(result);
|
||||
} catch (Exception e) {
|
||||
log.error("触发智能文件向量化失败: fileId={}", fileId, e);
|
||||
return error("触发智能文件向量化失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "批量智能文件向量化", description = "批量自动判断部门向量库并执行向量化")
|
||||
@PostMapping("/vectorize/batch/smart")
|
||||
public Result<Map<String, Object>> batchSmartVectorizeFiles(
|
||||
@Parameter(description = "文件ID列表", required = true) @RequestParam List<Long> fileIds,
|
||||
HttpServletRequest request) {
|
||||
try {
|
||||
@@ -453,7 +516,7 @@ public class CollectionController extends BaseController {
|
||||
return error("用户未登录");
|
||||
}
|
||||
|
||||
log.info("触发批量文件向量化: collection={}, fileIds={}, userId={}", name, fileIds, userId);
|
||||
log.info("触发批量智能文件向量化: fileIds={}, userId={}", fileIds, userId);
|
||||
|
||||
List<Map<String, Object>> submittedFiles = new ArrayList<>();
|
||||
List<Map<String, Object>> failedFiles = new ArrayList<>();
|
||||
@@ -488,25 +551,27 @@ public class CollectionController extends BaseController {
|
||||
continue;
|
||||
}
|
||||
|
||||
String collectionName = determineCollectionForFile(file);
|
||||
log.info("自动确定向量库名称: fileId={}, collection={}", fileId, collectionName);
|
||||
|
||||
asyncVectorizeService.updateFileProcessStatus(fileId,
|
||||
AsyncVectorizeService.VECTORIZE_STATUS_VECTORIZING,
|
||||
String.format("正在向量化,请稍后...(批次ID: %s)", batchId));
|
||||
|
||||
asyncVectorizeService.batchVectorizeFilesAsync(name, fileId, userId, batchId);
|
||||
asyncVectorizeService.batchVectorizeFilesAsync(collectionName, fileId, userId, batchId);
|
||||
|
||||
Map<String, Object> 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);
|
||||
submitted.put("collection", collectionName);
|
||||
submitted.put("status", "SUBMITTED");
|
||||
submittedFiles.add(submitted);
|
||||
|
||||
log.info("批量向量化任务已提交: collection={}, fileId={}, fileName={}, batchId={}",
|
||||
name, fileId, file.getFileName(), batchId);
|
||||
log.info("文件向量化任务已提交: fileId={}, fileName={}, collection={}, batchId={}",
|
||||
fileId, file.getFileName(), collectionName, batchId);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("提交文件向量化失败: fileId={}", fileId, e);
|
||||
log.error("提交文件向量化任务失败: fileId={}", fileId, e);
|
||||
Map<String, Object> failed = new HashMap<>();
|
||||
failed.put("fileId", fileId);
|
||||
failed.put("error", e.getMessage());
|
||||
@@ -516,7 +581,8 @@ public class CollectionController extends BaseController {
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", true);
|
||||
result.put("collection", name);
|
||||
result.put("message", String.format("批量向量化任务已提交: 提交 %d 个, 无效 %d 个",
|
||||
submittedFiles.size(), failedFiles.size()));
|
||||
result.put("batchId", batchId);
|
||||
result.put("submittedFiles", submittedFiles);
|
||||
result.put("failedFiles", failedFiles);
|
||||
@@ -530,11 +596,310 @@ public class CollectionController extends BaseController {
|
||||
|
||||
return success(result);
|
||||
} catch (Exception e) {
|
||||
log.error("触发批量文件向量化失败: collection={}", name, e);
|
||||
return error("触发批量文件向量化失败: " + e.getMessage());
|
||||
log.error("触发批量智能文件向量化失败", e);
|
||||
return error("触发批量智能文件向量化失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ... existing code ...
|
||||
|
||||
private String determineCollectionForFile(File file) {
|
||||
try {
|
||||
Integer userType = 3;
|
||||
Long deptId = file.getDeptId();
|
||||
Boolean isPublic = file.getIsPublic() != null && file.getIsPublic() == 1;
|
||||
|
||||
if (deptId != null) {
|
||||
String deptName = getDepartmentName(deptId);
|
||||
|
||||
LambdaQueryWrapper<KnowledgeBasePath> 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) {
|
||||
log.info("找到现有向量库: collection={}, department={}", kb.getName(), deptName);
|
||||
return kb.getName();
|
||||
}
|
||||
|
||||
String newCollectionName = "dept_" + deptId + "_kb";
|
||||
log.info("未找到向量库,将自动创建: collection={}, department={}", newCollectionName, deptName);
|
||||
return newCollectionName;
|
||||
} else {
|
||||
String collectionName = isPublic ? "public_kb" : "private_kb";
|
||||
log.info("无部门信息,使用默认向量库: collection={}", collectionName);
|
||||
return collectionName;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("确定向量库名称失败,使用默认值", e);
|
||||
return "public_kb";
|
||||
}
|
||||
}
|
||||
|
||||
// ... existing code ...
|
||||
|
||||
|
||||
// ... existing code ...
|
||||
|
||||
|
||||
// ... existing code ...
|
||||
|
||||
|
||||
@Resource
|
||||
private top.tqx.demo_1.service.ExamService examService;
|
||||
|
||||
@Resource
|
||||
private top.tqx.demo_1.service.QuestionService questionService;
|
||||
|
||||
@Operation(summary = "文件生成题目", description = "对已向量化完成的文件生成考试题目")
|
||||
@PostMapping("/{name}/generate-exam")
|
||||
public Result<Map<String, Object>> generateExamFromFile(
|
||||
@Parameter(description = "向量库名称", required = true) @PathVariable String name,
|
||||
@Parameter(description = "文件ID", required = true) @RequestParam Long fileId,
|
||||
@Parameter(description = "题目类型(逗号分隔)", example = "single,multiple") @RequestParam(required = false) String questionTypes,
|
||||
@Parameter(description = "难度等级(1-5)", example = "3") @RequestParam(required = false) Integer difficulty,
|
||||
@Parameter(description = "题目数量", example = "10") @RequestParam(required = false) Integer questionCount,
|
||||
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("文件不存在");
|
||||
}
|
||||
|
||||
if (file.getVectorDbAddress() == null || file.getVectorDbAddress().isEmpty()) {
|
||||
log.warn("文件未完成向量化,无法生成题目: fileId={}", fileId);
|
||||
return error("文件未完成向量化,请先进行向量化处理");
|
||||
}
|
||||
|
||||
if (questionTypes == null || questionTypes.isEmpty()) {
|
||||
questionTypes = "single";
|
||||
}
|
||||
|
||||
if (difficulty == null) {
|
||||
difficulty = 3;
|
||||
}
|
||||
|
||||
if (questionCount == null) {
|
||||
questionCount = 10;
|
||||
}
|
||||
|
||||
top.tqx.demo_1.dto.ExamGenerateRequest examRequest = new top.tqx.demo_1.dto.ExamGenerateRequest();
|
||||
examRequest.setFileId(fileId);
|
||||
examRequest.setQuestionTypes(questionTypes);
|
||||
examRequest.setDifficulty(difficulty);
|
||||
examRequest.setQuestionCount(questionCount);
|
||||
examRequest.setRequestId("EXAM_" + System.currentTimeMillis() + "_" + fileId);
|
||||
|
||||
asyncVectorizeService.updateFileProcessStatus(fileId,
|
||||
"GENERATING_EXAM",
|
||||
"正在生成题目,请稍后...");
|
||||
|
||||
java.util.concurrent.CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
log.info("开始异步生成题目: fileId={}, requestId={}", fileId, examRequest.getRequestId());
|
||||
Map<String, Object> result = examService.generateQuestionsWithFileId(examRequest);
|
||||
|
||||
asyncVectorizeService.updateFileProcessStatus(fileId,
|
||||
"EXAM_GENERATED",
|
||||
"题目生成成功");
|
||||
|
||||
file.setExamStatus("GENERATED");
|
||||
fileMapper.updateById(file);
|
||||
|
||||
log.info("题目生成成功: fileId={}, result={}", fileId, result);
|
||||
} catch (Exception e) {
|
||||
log.error("题目生成失败: fileId={}", fileId, e);
|
||||
asyncVectorizeService.updateFileProcessStatus(fileId,
|
||||
"EXAM_FAILED",
|
||||
"题目生成失败: " + e.getMessage());
|
||||
|
||||
file.setExamStatus("FAILED");
|
||||
file.setProcessMessage("题目生成失败: " + e.getMessage());
|
||||
fileMapper.updateById(file);
|
||||
}
|
||||
});
|
||||
|
||||
Map<String, Object> 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("requestId", examRequest.getRequestId());
|
||||
result.put("status", "GENERATING");
|
||||
|
||||
log.info("文件题目生成任务已提交: collection={}, fileId={}, fileName={}, requestId={}",
|
||||
name, fileId, file.getFileName(), examRequest.getRequestId());
|
||||
|
||||
return success(result);
|
||||
} catch (Exception e) {
|
||||
log.error("触发文件生成题目失败: collection={}, fileId={}", name, fileId, e);
|
||||
return error("触发文件生成题目失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "级联软删除文件及题目", description = "软删除文件并自动软删除该文件对应的所有题目")
|
||||
@PostMapping("/cascade-delete/{fileId}")
|
||||
public Result<Map<String, Object>> cascadeSoftDeleteFile(
|
||||
@Parameter(description = "文件ID", required = true) @PathVariable Long fileId,
|
||||
HttpServletRequest request) {
|
||||
try {
|
||||
Long userId = getCurrentUserId(request);
|
||||
if (userId == null) {
|
||||
return error("用户未登录");
|
||||
}
|
||||
|
||||
log.info("开始级联软删除: fileId={}, userId={}", fileId, userId);
|
||||
|
||||
File file = fileMapper.selectById(fileId);
|
||||
if (file == null) {
|
||||
log.warn("文件不存在: fileId={}", fileId);
|
||||
return error("文件不存在");
|
||||
}
|
||||
|
||||
if (file.getStatus() != null && file.getStatus() == 0) {
|
||||
log.warn("文件已被软删除: fileId={}", fileId);
|
||||
return error("文件已被软删除");
|
||||
}
|
||||
|
||||
String documentName = file.getFileName();
|
||||
|
||||
int deletedQuestionCount = questionService.softDeleteByFileId(fileId);
|
||||
log.info("题目软删除完成: fileId={}, documentName={}, 删除数量={}",
|
||||
fileId, documentName, deletedQuestionCount);
|
||||
|
||||
file.setStatus(0);
|
||||
file.setUpdateTime(LocalDateTime.now());
|
||||
file.setProcessStatus("DELETED");
|
||||
file.setProcessStepStatus("DELETED");
|
||||
file.setProcessStepMessage("文件已软删除");
|
||||
fileMapper.updateById(file);
|
||||
|
||||
log.info("文件软删除完成: fileId={}, fileName={}", fileId, documentName);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", true);
|
||||
result.put("message", "文件和题目已成功软删除");
|
||||
result.put("fileId", fileId);
|
||||
result.put("fileName", documentName);
|
||||
result.put("deletedQuestionCount", deletedQuestionCount);
|
||||
|
||||
return success(result);
|
||||
} catch (Exception e) {
|
||||
log.error("级联软删除失败: fileId={}", fileId, e);
|
||||
return error("级联软删除失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "删除文件向量化数据", description = "删除文件的向量化数据,如果存在关联题目则先软删除题目")
|
||||
@PostMapping("/{name}/devectorize")
|
||||
public Result<Map<String, Object>> devectorizeFile(
|
||||
@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("文件不存在");
|
||||
}
|
||||
|
||||
if (file.getVectorDbAddress() == null || file.getVectorDbAddress().isEmpty()) {
|
||||
log.warn("文件没有向量化数据: fileId={}", fileId);
|
||||
return error("文件没有向量化数据");
|
||||
}
|
||||
|
||||
int deletedQuestionCount = questionService.softDeleteByFileId(fileId);
|
||||
if (deletedQuestionCount > 0) {
|
||||
log.info("检测到关联题目,已软删除: fileId={}, 删除数量={}", fileId, deletedQuestionCount);
|
||||
}
|
||||
|
||||
String vectorDbAddress = file.getVectorDbAddress();
|
||||
deleteDocumentFromRAG(vectorDbAddress);
|
||||
log.info("已删除RAG服务中的向量化数据: fileId={}, vectorDbAddress={}", fileId, vectorDbAddress);
|
||||
|
||||
file.setVectorDbAddress(null);
|
||||
file.setProcessStatus("PENDING");
|
||||
file.setProcessStepStatus(FileProcessStepStatus.UPLOADED.getCode());
|
||||
file.setProcessStepMessage(FileProcessStepStatus.UPLOADED.getDescription());
|
||||
file.setUpdateTime(LocalDateTime.now());
|
||||
fileMapper.updateById(file);
|
||||
|
||||
CollectionFile cf = collectionFileService.findByFileId(fileId);
|
||||
if (cf != null) {
|
||||
collectionFileService.delete(cf.getId());
|
||||
log.info("已删除本地数据库文档关联: fileId={}", fileId);
|
||||
}
|
||||
|
||||
triggerSync();
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", true);
|
||||
result.put("message", "向量化数据已删除");
|
||||
result.put("fileId", fileId);
|
||||
result.put("fileName", file.getFileName());
|
||||
result.put("deletedQuestionCount", deletedQuestionCount);
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteDocumentFromRAG(String vectorDbAddress) {
|
||||
try {
|
||||
if (vectorDbAddress == null || vectorDbAddress.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String[] parts = vectorDbAddress.split("/");
|
||||
if (parts.length < 2) {
|
||||
log.warn("无效的vectorDbAddress格式: {}", vectorDbAddress);
|
||||
return;
|
||||
}
|
||||
|
||||
String collection = parts[parts.length - 2];
|
||||
String filename = parts[parts.length - 1];
|
||||
|
||||
String url = aiApiConfig.getFullUrl(aiApiConfig.getDocument().getDelete());
|
||||
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8);
|
||||
url = url + "/" + collection + "/" + encodedFilename;
|
||||
log.info("调用RAG服务删除文档: {}", url);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.DELETE, null, String.class);
|
||||
|
||||
if (!response.getStatusCode().is2xxSuccessful()) {
|
||||
log.warn("删除RAG文档失败: status={}, body={}", response.getStatusCode(), response.getBody());
|
||||
} else {
|
||||
log.info("RAG文档删除成功: collection={}, filename={}", collection, filename);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("删除RAG文档异常: vectorDbAddress={}", vectorDbAddress, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Operation(summary = "获取文档列表", description = "从本地数据库获取指定向量库中的文档列表")
|
||||
@GetMapping("/{name}/documents")
|
||||
public Result<List<Map<String, Object>>> getDocuments(
|
||||
@@ -584,7 +949,8 @@ public class CollectionController extends BaseController {
|
||||
}
|
||||
|
||||
String url = aiApiConfig.getFullUrl(aiApiConfig.getDocument().getDelete());
|
||||
url = url + "/" + name + "/" + filename;
|
||||
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8);
|
||||
url = url + "/" + name + "/" + encodedFilename;
|
||||
log.info("调用RAG服务删除文档: {}", url);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.DELETE, null, String.class);
|
||||
|
||||
@@ -16,9 +16,9 @@ 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.mapper.QuestionMapper;
|
||||
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;
|
||||
@@ -43,9 +43,6 @@ public class ExamController extends BaseController {
|
||||
@Autowired
|
||||
private ExamService examService;
|
||||
|
||||
@Autowired
|
||||
private ExamPaperService examPaperService;
|
||||
|
||||
@Autowired
|
||||
private AuthService authService;
|
||||
|
||||
@@ -61,21 +58,8 @@ public class ExamController extends BaseController {
|
||||
@Autowired
|
||||
private AsyncExamService asyncExamService;
|
||||
|
||||
@Operation(summary = "获取试卷详情", description = "通过试卷ID获取试卷详情")
|
||||
@GetMapping("/paper/{paperId}")
|
||||
public Result<ExamPaperResponse> 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());
|
||||
}
|
||||
}
|
||||
@Autowired
|
||||
private QuestionMapper questionMapper;
|
||||
|
||||
@Operation(summary = "开始考试(获取题目)", description = "通过试卷ID开始模拟考试,同时返回试卷题目内容")
|
||||
@PostMapping("/paper/{paperId}/start")
|
||||
@@ -183,15 +167,79 @@ public class ExamController extends BaseController {
|
||||
request.setRequestId(UUID.randomUUID().toString());
|
||||
}
|
||||
|
||||
// 校验总题数上限(≤20)
|
||||
if (request.getQuestionTypes() instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Integer> questionTypes = (Map<String, Integer>) request.getQuestionTypes();
|
||||
int totalQuestions = questionTypes.values().stream().mapToInt(Integer::intValue).sum();
|
||||
if (totalQuestions > 20) {
|
||||
feedback.put("errorType", "VALIDATION_ERROR");
|
||||
feedback.put("errorDetail", "总题数不能超过20道");
|
||||
feedback.put("suggestion", "请减少题目数量,每种题型数量之和不能超过20");
|
||||
return Result.error(400, "参数验证失败", feedback);
|
||||
}
|
||||
}
|
||||
|
||||
// 查询该文件已有的题目题干,用于去重
|
||||
if (request.getFileId() != null) {
|
||||
String documentId = request.getFileId().toString();
|
||||
log.info("查询已有题目题干,documentId={}", documentId);
|
||||
List<String> existingStems = questionMapper.selectStemsByDocumentId(documentId);
|
||||
if (existingStems != null && !existingStems.isEmpty()) {
|
||||
request.setExcludeStems(existingStems);
|
||||
log.info("已查询到该文件已有 {} 道题目,将用于去重", existingStems.size());
|
||||
} else {
|
||||
log.warn("未查询到该文件的已有题目,documentId={}, existingStems={}", documentId,
|
||||
existingStems == null ? "null" : "empty list");
|
||||
}
|
||||
}
|
||||
|
||||
log.info("========== 生成题目请求开始 ==========");
|
||||
log.info("请求参数: userId={}, requestId={}, fileId={}, filePath={}, questionTypes={}, difficulty={}",
|
||||
log.info("请求参数: userId={}, requestId={}, fileId={}, filePath={}, questionTypes={}, difficulty={}, excludeStemsCount={}",
|
||||
userId, request.getRequestId(), request.getFileId(), request.getFilePath(),
|
||||
request.getQuestionTypes(), request.getDifficulty());
|
||||
request.getQuestionTypes(), request.getDifficulty(),
|
||||
request.getExcludeStems() != null ? request.getExcludeStems().size() : 0);
|
||||
|
||||
try {
|
||||
Map<String, Object> result = examService.generateQuestionsWithFileId(request);
|
||||
|
||||
feedback.putAll(result);
|
||||
|
||||
// 判断文件匹配状态,只有 success 才启动异步生成任务
|
||||
Object fileMatchStatus = result.get("fileMatchStatus");
|
||||
if (fileMatchStatus == null || !"success".equals(fileMatchStatus.toString())) {
|
||||
feedback.put("status", "error");
|
||||
feedback.put("errorType", "FILE_VALIDATION_ERROR");
|
||||
feedback.put("errorDetail", result.get("message") != null ? result.get("message").toString() : "文件验证失败");
|
||||
feedback.put("suggestion", "请检查 file_id 是否正确、文件是否已删除、或文件是否已完成向量化");
|
||||
|
||||
// 映射不同失败原因到不同的提示/状态码
|
||||
String status = fileMatchStatus != null ? fileMatchStatus.toString() : "UNKNOWN";
|
||||
int httpCode = "INTERNAL_ERROR".equals(status) ? 500 : 400;
|
||||
String userMsg;
|
||||
switch (status) {
|
||||
case "FILE_NOT_FOUND":
|
||||
userMsg = "文件不存在,请确认 file_id 是否正确";
|
||||
break;
|
||||
case "FILE_DELETED":
|
||||
userMsg = "文件已被删除,无法生成题目";
|
||||
break;
|
||||
case "NOT_VECTORIZED":
|
||||
userMsg = "文件尚未完成向量化,请等待文件处理完成后再试";
|
||||
break;
|
||||
case "PARAM_MISSING":
|
||||
userMsg = "参数不完整:file_id 与 file_path 不能同时为空";
|
||||
break;
|
||||
case "INTERNAL_ERROR":
|
||||
userMsg = "服务器内部错误";
|
||||
break;
|
||||
default:
|
||||
userMsg = "文件验证失败";
|
||||
}
|
||||
log.error("生成题目文件验证失败: fileMatchStatus={}, detail={}", status, feedback.get("errorDetail"));
|
||||
return Result.error(httpCode, userMsg, feedback);
|
||||
}
|
||||
|
||||
feedback.put("status", "success");
|
||||
feedback.put("message", "收到请求,正在生成题目");
|
||||
|
||||
@@ -311,9 +359,9 @@ public class ExamController extends BaseController {
|
||||
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.getContent() == null) {
|
||||
log.error("第{}个答案缺少content, questionId={}", i+1, answer.getQuestionId());
|
||||
return Result.error(400, String.format("第%d个答案的content不能为空 (questionId: %s)", i+1, answer.getQuestionId()));
|
||||
}
|
||||
if (answer.getMaxScore() == null) {
|
||||
log.error("第{}个答案缺少maxScore, questionId={}", i+1, answer.getQuestionId());
|
||||
|
||||
@@ -38,7 +38,9 @@ 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;
|
||||
|
||||
@Tag(name = "文件管理", description = "文件的上传、下载、删除等操作")
|
||||
@RestController
|
||||
@@ -57,6 +59,9 @@ public class FileController extends BaseController {
|
||||
|
||||
@Autowired
|
||||
private UserMapper userMapper;
|
||||
|
||||
@Autowired
|
||||
private top.tqx.demo_1.service.QuestionService questionService;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Value("${file.upload.path:./uploads/}")
|
||||
private String uploadPath;
|
||||
@@ -103,6 +108,7 @@ public class FileController extends BaseController {
|
||||
@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,
|
||||
@Parameter(description = "是否自动向量化", example = "false") @RequestParam(required = false, defaultValue = "false") Boolean autoVectorize,
|
||||
HttpServletRequest request) {
|
||||
try {
|
||||
FileUtil.validateFile(file);
|
||||
@@ -117,7 +123,7 @@ public class FileController extends BaseController {
|
||||
return error("用户信息获取失败");
|
||||
}
|
||||
|
||||
FileVO fileVO = fileService.uploadFile(file, userId, user.getDeptId(), user.getUserType(), deptId, isPublic);
|
||||
FileVO fileVO = fileService.uploadFile(file, userId, user.getDeptId(), user.getUserType(), deptId, isPublic, autoVectorize);
|
||||
|
||||
if (description != null && !description.isEmpty()) {
|
||||
fileService.updateFileDescription(fileVO.getId(), description);
|
||||
@@ -443,39 +449,67 @@ public class FileController extends BaseController {
|
||||
".css".equals(extension);
|
||||
}
|
||||
|
||||
@Operation(summary = "删除文件", description = "根据ID删除文件")
|
||||
@Operation(summary = "删除文件", description = "软删除文件,同时级联软删除该文件对应的所有题目")
|
||||
@DeleteMapping("/{id}")
|
||||
public Result<Void> deleteFile(@Parameter(description = "文件ID") @PathVariable Long id, HttpServletRequest request) {
|
||||
Long userId = getCurrentUserId(request);
|
||||
if (userId == null) {
|
||||
return error("用户未登录");
|
||||
public Result<Map<String, Object>> deleteFile(@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 file = fileService.getFileEntityById(id);
|
||||
if (file == null) {
|
||||
return error("文件不存在");
|
||||
}
|
||||
|
||||
if (file.getStatus() != null && file.getStatus() == 0) {
|
||||
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("无权限删除此文件");
|
||||
}
|
||||
|
||||
int deletedQuestionCount = questionService.softDeleteByFileId(id);
|
||||
log.info("题目软删除完成: fileId={}, documentName={}, 删除数量={}",
|
||||
id, file.getFileName(), deletedQuestionCount);
|
||||
|
||||
file.setStatus(0);
|
||||
file.setUpdateTime(LocalDateTime.now());
|
||||
file.setProcessStatus("DELETED");
|
||||
file.setProcessStepStatus("DELETED");
|
||||
file.setProcessStepMessage("文件已软删除");
|
||||
fileService.updateFileEntity(file);
|
||||
|
||||
log.info("文件软删除完成: fileId={}, fileName={}", id, file.getFileName());
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", true);
|
||||
result.put("message", "文件和题目已成功软删除");
|
||||
result.put("fileId", id);
|
||||
result.put("fileName", file.getFileName());
|
||||
result.put("deletedQuestionCount", deletedQuestionCount);
|
||||
|
||||
return success(result);
|
||||
} catch (Exception e) {
|
||||
log.error("文件软删除失败: fileId={}", id, e);
|
||||
return error("文件软删除失败: " + e.getMessage());
|
||||
}
|
||||
|
||||
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 = "管理员审核通过用户上传的公开文件")
|
||||
@@ -615,6 +649,12 @@ public class FileController extends BaseController {
|
||||
file.setAuditStatus(request.getAuditStatus());
|
||||
}
|
||||
|
||||
if (request.getTags() != null) {
|
||||
com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
file.setTags(objectMapper.valueToTree(request.getTags()));
|
||||
log.info("文件标签已更新: fileId={}, tags={}", id, request.getTags());
|
||||
}
|
||||
|
||||
file.setUpdateTime(LocalDateTime.now());
|
||||
fileService.updateFileEntity(file);
|
||||
|
||||
|
||||
@@ -83,37 +83,12 @@ public class ImageController {
|
||||
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;
|
||||
return imageService.getImage(id);
|
||||
} catch (Exception e) {
|
||||
log.error("获取图片失败: id={}, error={}", id, e.getMessage());
|
||||
// 返回一个1x1的透明像素作为占位符,避免页面报错
|
||||
return createPlaceholderImage();
|
||||
log.error("获取图片失败: {}", e.getMessage(), e);
|
||||
throw new RuntimeException("获取图片失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个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}")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package top.tqx.demo_1.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -14,14 +14,14 @@ 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.AuthService;
|
||||
import top.tqx.demo_1.service.QuestionService;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
@Tag(name = "题目管理", description = "题目的增删改查管理接口")
|
||||
@RestController
|
||||
@RequestMapping("/question")
|
||||
@@ -31,6 +31,9 @@ public class QuestionController extends BaseController {
|
||||
@Autowired
|
||||
private QuestionService questionService;
|
||||
|
||||
@Autowired
|
||||
private AuthService authService;
|
||||
|
||||
@Operation(summary = "创建题目", description = "创建新的题目记录")
|
||||
@PostMapping
|
||||
public Result<Question> createQuestion(@RequestBody QuestionCreateRequest request) {
|
||||
@@ -66,11 +69,11 @@ public class QuestionController extends BaseController {
|
||||
return Result.success(result);
|
||||
}
|
||||
|
||||
@Operation(summary = "获取题目详情", description = "根据题目UUID获取题目详细信息")
|
||||
@GetMapping("/{questionId}")
|
||||
@Operation(summary = "获取题目详情", description = "根据题目ID获取题目详细信息")
|
||||
@GetMapping("/{id}")
|
||||
public Result<Question> getQuestionById(
|
||||
@Parameter(description = "题目UUID") @PathVariable String questionId) {
|
||||
Question question = questionService.getByQuestionId(questionId);
|
||||
@Parameter(description = "题目ID") @PathVariable Long id) {
|
||||
Question question = questionService.getById(id);
|
||||
if (question == null) {
|
||||
return Result.error(404, "题目不存在");
|
||||
}
|
||||
@@ -85,38 +88,64 @@ public class QuestionController extends BaseController {
|
||||
}
|
||||
|
||||
@Operation(summary = "更新题目", description = "更新题目信息,支持部分字段更新")
|
||||
@PutMapping("/{questionId}")
|
||||
@PutMapping("/{id}")
|
||||
public Result<Question> updateQuestion(
|
||||
@Parameter(description = "题目UUID") @PathVariable String questionId,
|
||||
@Parameter(description = "题目ID(支持Long类型的id或UUID格式的questionId)") @PathVariable String id,
|
||||
@RequestBody QuestionUpdateRequest request) {
|
||||
try {
|
||||
Question existing = questionService.getByQuestionId(questionId);
|
||||
Question existing = null;
|
||||
|
||||
if (isNumeric(id)) {
|
||||
existing = questionService.getById(Long.parseLong(id));
|
||||
log.info("通过自增ID查询题目: id={}", id);
|
||||
} else {
|
||||
LambdaQueryWrapper<Question> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(Question::getQuestionId, id);
|
||||
existing = questionService.getOne(wrapper);
|
||||
log.info("通过UUID查询题目: questionId={}", id);
|
||||
}
|
||||
|
||||
if (existing == null) {
|
||||
return Result.error(404, "题目不存在");
|
||||
return Result.error(404, "题目不存在: " + id);
|
||||
}
|
||||
|
||||
Question updated = questionService.updateQuestion(existing.getId(), request);
|
||||
log.info("更新题目成功: questionId={}", questionId);
|
||||
log.info("更新题目成功: id={}, questionId={}", existing.getId(), existing.getQuestionId());
|
||||
return Result.success("题目更新成功", updated);
|
||||
} catch (NumberFormatException e) {
|
||||
log.error("ID格式错误: {}", id, e);
|
||||
return Result.error(400, "ID格式错误,请使用数字ID或有效的UUID");
|
||||
} catch (Exception e) {
|
||||
log.error("更新题目失败: {}", e.getMessage(), e);
|
||||
return Result.error(500, "更新题目失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "删除题目", description = "根据UUID删除题目")
|
||||
@DeleteMapping("/{questionId}")
|
||||
public Result<Void> deleteQuestion(
|
||||
@Parameter(description = "题目UUID") @PathVariable String questionId) {
|
||||
private boolean isNumeric(String str) {
|
||||
if (str == null || str.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Question existing = questionService.getByQuestionId(questionId);
|
||||
Long.parseLong(str);
|
||||
return true;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "删除题目", description = "根据ID删除题目")
|
||||
@DeleteMapping("/{id}")
|
||||
public Result<Void> deleteQuestion(
|
||||
@Parameter(description = "题目ID") @PathVariable Long id) {
|
||||
try {
|
||||
Question existing = questionService.getById(id);
|
||||
if (existing == null) {
|
||||
return Result.error(404, "题目不存在");
|
||||
}
|
||||
|
||||
boolean success = questionService.deleteById(existing.getId());
|
||||
boolean success = questionService.deleteById(id);
|
||||
if (success) {
|
||||
log.info("删除题目成功: questionId={}", questionId);
|
||||
log.info("删除题目成功: id={}", id);
|
||||
return Result.success("题目删除成功");
|
||||
} else {
|
||||
return Result.error(500, "删除题目失败");
|
||||
|
||||
@@ -213,7 +213,7 @@ public class WrongQuestionController extends BaseController {
|
||||
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() : ""),
|
||||
escapeCsv(item.getExcludeStems() != null ? item.getExcludeStems() : ""),
|
||||
item.getWrongCount() != null ? item.getWrongCount() : 1,
|
||||
item.getAnsweredAt() != null ? item.getAnsweredAt().toString() : ""
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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;
|
||||
@@ -55,7 +56,12 @@ public class ExamGenerateRequest implements Serializable {
|
||||
@Schema(description = "自定义题型分数配置,仅在指定题型模式下有效")
|
||||
private Map<String, BigDecimal> questionScores; // 各题型分数配置
|
||||
|
||||
@JsonProperty("exclude_stems")
|
||||
@Schema(description = "已有题目的题干列表,用于去重,最多100条")
|
||||
private List<String> excludeStems;
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class QuestionContent implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@@ -70,6 +76,9 @@ public class ExamGenerateRequest implements Serializable {
|
||||
|
||||
@JsonProperty("explanation")
|
||||
private String explanation;
|
||||
|
||||
@JsonProperty("referenced_chunk_ids")
|
||||
private List<String> referencedChunkIds;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -7,6 +7,7 @@ import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@@ -63,6 +64,18 @@ public class ExamGenerateResponse implements Serializable {
|
||||
@Schema(description = "生成的题目列表")
|
||||
private List<ExamGenerateRequest.Question> questions;
|
||||
|
||||
@JsonProperty("requested_types")
|
||||
@Schema(description = "请求的题型和数量")
|
||||
private Map<String, Integer> requestedTypes;
|
||||
|
||||
@JsonProperty("actual_types")
|
||||
@Schema(description = "实际生成的题型和数量")
|
||||
private Map<String, Integer> actualTypes;
|
||||
|
||||
@JsonProperty("warnings")
|
||||
@Schema(description = "短缺提示数组")
|
||||
private List<String> warnings;
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class ResponseData implements Serializable {
|
||||
@@ -85,6 +98,45 @@ public class ExamGenerateResponse implements Serializable {
|
||||
|
||||
@JsonProperty("success")
|
||||
private Boolean success;
|
||||
|
||||
@JsonProperty("requested_types")
|
||||
private Map<String, Integer> requestedTypes;
|
||||
|
||||
@JsonProperty("actual_types")
|
||||
private Map<String, Integer> actualTypes;
|
||||
|
||||
@JsonProperty("warnings")
|
||||
private List<String> warnings;
|
||||
}
|
||||
|
||||
public Map<String, Integer> getRequestedTypes() {
|
||||
if (requestedTypes != null) {
|
||||
return requestedTypes;
|
||||
}
|
||||
if (data != null && data.getRequestedTypes() != null) {
|
||||
return data.getRequestedTypes();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public Map<String, Integer> getActualTypes() {
|
||||
if (actualTypes != null) {
|
||||
return actualTypes;
|
||||
}
|
||||
if (data != null && data.getActualTypes() != null) {
|
||||
return data.getActualTypes();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public List<String> getWarnings() {
|
||||
if (warnings != null) {
|
||||
return warnings;
|
||||
}
|
||||
if (data != null && data.getWarnings() != null) {
|
||||
return data.getWarnings();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public Object getAiAnalysis() {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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;
|
||||
|
||||
@@ -50,9 +49,9 @@ public class ExamGradeRequest implements Serializable {
|
||||
@JsonAlias("questionType")
|
||||
private String questionType;
|
||||
|
||||
@JsonProperty("question_content")
|
||||
@JsonAlias("questionContent")
|
||||
private QuestionContent questionContent;
|
||||
@JsonProperty("content")
|
||||
@JsonAlias({"questionContent", "question_content"})
|
||||
private QuestionContent content;
|
||||
|
||||
@JsonProperty("student_answer")
|
||||
@JsonAlias("studentAnswer")
|
||||
@@ -64,7 +63,6 @@ public class ExamGradeRequest implements Serializable {
|
||||
}
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class QuestionContent implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@@ -76,9 +74,6 @@ public class ExamGradeRequest implements Serializable {
|
||||
|
||||
@JsonProperty("answer")
|
||||
private Object answer;
|
||||
|
||||
@JsonProperty("explanation")
|
||||
private String explanation;
|
||||
}
|
||||
|
||||
public enum QuestionType {
|
||||
|
||||
@@ -150,6 +150,9 @@ public class ExamGradeResponse implements Serializable {
|
||||
@JsonProperty("max_score")
|
||||
private BigDecimal maxScore;
|
||||
|
||||
@JsonProperty("grading_status")
|
||||
private String gradingStatus;
|
||||
|
||||
@JsonProperty("correct")
|
||||
private Boolean correct;
|
||||
|
||||
@@ -176,6 +179,27 @@ public class ExamGradeResponse implements Serializable {
|
||||
public static class DetailInfo implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@JsonProperty("correct")
|
||||
private Boolean correct;
|
||||
|
||||
@JsonProperty("student_answer")
|
||||
private Object studentAnswer;
|
||||
|
||||
@JsonProperty("correct_answer")
|
||||
private Object correctAnswer;
|
||||
|
||||
@JsonProperty("feedback")
|
||||
private String feedback;
|
||||
|
||||
@JsonProperty("total_blanks")
|
||||
private Integer totalBlanks;
|
||||
|
||||
@JsonProperty("correct_blanks")
|
||||
private Integer correctBlanks;
|
||||
|
||||
@JsonProperty("blank_scores")
|
||||
private List<BigDecimal> blankScores;
|
||||
|
||||
@JsonProperty("highlights")
|
||||
private List<String> highlights;
|
||||
|
||||
@@ -187,6 +211,12 @@ public class ExamGradeResponse implements Serializable {
|
||||
|
||||
@JsonProperty("shortcomings")
|
||||
private List<String> shortcomings;
|
||||
|
||||
@JsonProperty("warnings")
|
||||
private List<String> warnings;
|
||||
|
||||
@JsonProperty("error")
|
||||
private String error;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -60,7 +60,7 @@ public class ExamPaperGenerateResponse implements Serializable {
|
||||
private Object content;
|
||||
|
||||
@JsonProperty("document_name")
|
||||
private String documentName;
|
||||
private String excludeStems;
|
||||
|
||||
@JsonProperty("knowledge_base_path")
|
||||
private String knowledgeBasePath;
|
||||
|
||||
@@ -52,12 +52,8 @@ public class ExamRecordResponse implements Serializable {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package top.tqx.demo_1.dto;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
public class FileUpdateRequest implements Serializable {
|
||||
@@ -16,4 +17,6 @@ public class FileUpdateRequest implements Serializable {
|
||||
private Integer isPublic;
|
||||
|
||||
private Integer auditStatus;
|
||||
|
||||
private Map<String, Object> tags;
|
||||
}
|
||||
|
||||
@@ -14,45 +14,24 @@ public class ImageCreateRequest implements Serializable {
|
||||
@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;
|
||||
|
||||
|
||||
@@ -10,18 +10,9 @@ 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;
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ import java.math.BigDecimal;
|
||||
public class ReferenceResponse {
|
||||
|
||||
private String chunkId;
|
||||
private Integer chunkIndex;
|
||||
private String source;
|
||||
private String collection;
|
||||
private String docType;
|
||||
private Integer page;
|
||||
private Integer pageEnd;
|
||||
|
||||
@@ -58,7 +58,7 @@ public class WrongQuestionListResponse implements Serializable {
|
||||
private String feedback;
|
||||
|
||||
@JsonProperty("document_name")
|
||||
private String documentName;
|
||||
private String excludeStems;
|
||||
|
||||
@JsonProperty("knowledge_base_path")
|
||||
private String knowledgeBasePath;
|
||||
|
||||
@@ -37,8 +37,6 @@ public class ChatMessage implements Serializable {
|
||||
|
||||
private String images;
|
||||
|
||||
private String sources;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
|
||||
public Long getId() {
|
||||
@@ -137,14 +135,6 @@ public class ChatMessage implements Serializable {
|
||||
this.images = images;
|
||||
}
|
||||
|
||||
public String getSources() {
|
||||
return sources;
|
||||
}
|
||||
|
||||
public void setSources(String sources) {
|
||||
this.sources = sources;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreateTime() {
|
||||
return createTime;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,11 @@ public class ChatReference implements Serializable {
|
||||
|
||||
private String chunkId;
|
||||
|
||||
private String docPath;
|
||||
private Integer chunkIndex;
|
||||
|
||||
private Long fileId;
|
||||
|
||||
private String collection;
|
||||
|
||||
private String docName;
|
||||
|
||||
|
||||
@@ -61,7 +61,10 @@ public class File implements Serializable {
|
||||
|
||||
private String examStatus;
|
||||
|
||||
private String processStepStatus;
|
||||
private String processStepStatus;
|
||||
|
||||
private String processStepMessage;
|
||||
private String processStepMessage;
|
||||
|
||||
@TableField(value = "tags", typeHandler = JacksonTypeHandler.class)
|
||||
private JsonNode tags;
|
||||
}
|
||||
|
||||
@@ -17,32 +17,18 @@ public class Image {
|
||||
|
||||
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;
|
||||
|
||||
@@ -40,7 +40,7 @@ public class Question implements Serializable {
|
||||
|
||||
private String documentId;
|
||||
|
||||
private String documentName;
|
||||
private String excludeStems;
|
||||
|
||||
private String knowledgeBasePath;
|
||||
|
||||
@@ -71,4 +71,6 @@ public class Question implements Serializable {
|
||||
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
private Integer isDeleted;
|
||||
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ 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 org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import top.tqx.demo_1.common.Result;
|
||||
import top.tqx.demo_1.exception.PermissionDeniedException;
|
||||
|
||||
@@ -81,17 +84,58 @@ public class GlobalExceptionHandler {
|
||||
return Result.error(400, message);
|
||||
}
|
||||
|
||||
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public Result<Void> handleDataIntegrityViolation(DataIntegrityViolationException e) {
|
||||
String causeMsg = e.getMostSpecificCause().getMessage();
|
||||
log.error("数据完整性违规: {}", causeMsg);
|
||||
|
||||
String userMsg = "数据格式不符合要求";
|
||||
if (causeMsg != null) {
|
||||
if (causeMsg.contains("Out of range")) userMsg = "数值超出允许范围";
|
||||
else if (causeMsg.contains("Data too long")) userMsg = "输入内容过长";
|
||||
else if (causeMsg.contains("not null")) userMsg = "必填字段不能为空";
|
||||
}
|
||||
return Result.error(400, userMsg);
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public Result<Void> handleHttpMessageNotReadable(HttpMessageNotReadableException e) {
|
||||
log.error("请求体解析失败: {}", e.getMessage());
|
||||
String msg = "请求数据格式错误";
|
||||
if (e.getMessage() != null) {
|
||||
if (e.getMessage().contains("JSON")) msg = "JSON格式不正确";
|
||||
else if (e.getMessage().contains("content")) msg = "题目内容格式不正确";
|
||||
}
|
||||
return Result.error(400, msg);
|
||||
}
|
||||
|
||||
@ExceptionHandler(JsonProcessingException.class)
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public Result<Void> handleJsonProcessing(JsonProcessingException e) {
|
||||
log.error("JSON序列化失败: {}", e.getOriginalMessage(), e);
|
||||
return Result.error(400, "数据处理格式错误: " + e.getOriginalMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(RuntimeException.class)
|
||||
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
public Result<Void> handleRuntimeException(RuntimeException e) {
|
||||
log.error("运行时异常: type={}, message={}", e.getClass().getSimpleName(), e.getMessage());
|
||||
if (e.getMessage() != null && (e.getMessage().contains("SQL") || e.getMessage().contains("connection"))) {
|
||||
log.error("运行时异常: type={}, message={}",
|
||||
e.getClass().getSimpleName(), e.getMessage(), e);
|
||||
|
||||
String msg = e.getMessage() != null ? e.getMessage() : "未知错误";
|
||||
|
||||
if (msg.contains("SQL") || msg.contains("connection"))
|
||||
return Result.error(500, "数据库操作失败");
|
||||
}
|
||||
if (e.getMessage() != null && e.getMessage().contains("Access Denied")) {
|
||||
if (msg.contains("Access Denied"))
|
||||
return Result.error(403, "无权限访问");
|
||||
}
|
||||
return Result.error(500, "服务器内部错误");
|
||||
if (msg.contains("Out of range"))
|
||||
return Result.error(400, "数值超出允许范围");
|
||||
if (msg.contains("Data too long"))
|
||||
return Result.error(400, "输入内容过长");
|
||||
|
||||
return Result.error(500, "操作失败: " + msg);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
package top.tqx.demo_1.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import top.tqx.demo_1.entity.Question;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface QuestionMapper extends BaseMapper<Question> {
|
||||
|
||||
/**
|
||||
* 根据文档ID查询已有的题目题干,用于去重
|
||||
*/
|
||||
@Select("SELECT exclude_stems AS stem FROM question WHERE document_id = #{documentId} AND exclude_stems IS NOT NULL AND is_deleted = 0")
|
||||
List<String> selectStemsByDocumentId(String documentId);
|
||||
}
|
||||
|
||||
@@ -77,6 +77,26 @@ public class AsyncExamService {
|
||||
updateFileExamStatus(fileId, EXAM_STATUS_GENERATED, "警告:未生成任何题目。可能原因:文档内容为空、文档内容无法提取题目、或文档格式不支持。source_chunks_used=" + response.getSourceChunksUsed());
|
||||
} else {
|
||||
String processMessage = "生成成功,共" + response.getTotal() + "道题";
|
||||
|
||||
// 追加题型统计信息
|
||||
if (response.getRequestedTypes() != null || response.getActualTypes() != null) {
|
||||
processMessage += "\n\n【题型统计】";
|
||||
if (response.getRequestedTypes() != null) {
|
||||
processMessage += "\n请求题型: " + formatQuestionTypes(response.getRequestedTypes());
|
||||
}
|
||||
if (response.getActualTypes() != null) {
|
||||
processMessage += "\n实际生成: " + formatQuestionTypes(response.getActualTypes());
|
||||
}
|
||||
}
|
||||
|
||||
// 追加短缺警告
|
||||
if (response.getWarnings() != null && !response.getWarnings().isEmpty()) {
|
||||
processMessage += "\n\n【短缺警告】";
|
||||
for (String warning : response.getWarnings()) {
|
||||
processMessage += "\n- " + warning;
|
||||
}
|
||||
}
|
||||
|
||||
log.info("response.getAiAnalysis() = {}", response.getAiAnalysis());
|
||||
log.info("response.getAiAnalysis() == null? {}", response.getAiAnalysis() == null);
|
||||
if (response.getAiAnalysis() != null) {
|
||||
@@ -214,4 +234,32 @@ public class AsyncExamService {
|
||||
throw new RuntimeException("更新文件状态失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化题型统计信息
|
||||
*/
|
||||
private String formatQuestionTypes(java.util.Map<String, Integer> types) {
|
||||
if (types == null || types.isEmpty()) {
|
||||
return "{}";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder("{");
|
||||
java.util.Map<String, String> typeNames = new java.util.HashMap<>();
|
||||
typeNames.put("single_choice", "单选题");
|
||||
typeNames.put("multiple_choice", "多选题");
|
||||
typeNames.put("true_false", "判断题");
|
||||
typeNames.put("fill_blank", "填空题");
|
||||
typeNames.put("subjective", "主观题");
|
||||
|
||||
boolean first = true;
|
||||
for (java.util.Map.Entry<String, Integer> entry : types.entrySet()) {
|
||||
if (!first) {
|
||||
sb.append(", ");
|
||||
}
|
||||
String typeName = typeNames.getOrDefault(entry.getKey(), entry.getKey());
|
||||
sb.append(typeName).append(": ").append(entry.getValue()).append("道");
|
||||
first = false;
|
||||
}
|
||||
sb.append("}");
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ public interface ChatService {
|
||||
|
||||
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);
|
||||
ChatMessage saveAssistantMessage(Long sessionId, String content, String answer, String kbPaths, String messageType, Integer isFinished, Long durationMs, String images);
|
||||
|
||||
ChatReference saveReference(Long messageId, String chunkId, String docPath, String docName, Integer page, String excerpt, Double score);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import java.io.InputStream;
|
||||
|
||||
public interface FileService {
|
||||
|
||||
FileVO uploadFile(MultipartFile file, Long userId, Long deptId, Integer userType, Long targetDeptId, Boolean isPublic);
|
||||
FileVO uploadFile(MultipartFile file, Long userId, Long deptId, Integer userType, Long targetDeptId, Boolean isPublic, Boolean autoVectorize);
|
||||
|
||||
PageResult<FileVO> pageFiles(Integer pageNum, Integer pageSize, String fileName, top.tqx.demo_1.entity.User user);
|
||||
|
||||
|
||||
@@ -36,20 +36,4 @@ public interface ImageService {
|
||||
Image downloadAndSaveImageWithMetadata(Map<String, Object> imageMetadata);
|
||||
|
||||
Map<String, Object> 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<Image> findImagesByReference(String docName, Integer page, String chunkId);
|
||||
}
|
||||
@@ -38,4 +38,8 @@ public interface QuestionService extends IService<Question> {
|
||||
List<Question> listQuestions(String questionType, Integer difficulty, String status, String documentName, Integer limit, Long userId, Long deptId, Integer userType);
|
||||
|
||||
QuestionStatsResponse getQuestionStats(QuestionStatsRequest request);
|
||||
|
||||
int softDeleteByFileId(Long fileId);
|
||||
|
||||
int softDeleteByDocumentName(String documentName);
|
||||
}
|
||||
|
||||
@@ -18,18 +18,10 @@ 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
|
||||
@@ -49,9 +41,8 @@ public class AiChatServiceImpl implements AiChatService {
|
||||
@Autowired
|
||||
private ImageService imageService;
|
||||
|
||||
// Nginx 图片访问前缀(从配置文件读取)
|
||||
@Value("${file.upload.url-prefix:/files/}")
|
||||
private String imageUrlPrefix;
|
||||
@Autowired
|
||||
private org.springframework.web.client.RestTemplate restTemplate;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@@ -188,22 +179,9 @@ public class AiChatServiceImpl implements AiChatService {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info("收到检索来源信息: count={}(仅日志,不写入引用表)", sourcesNode.isArray() ? sourcesNode.size() : 0);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -218,20 +196,7 @@ public class AiChatServiceImpl implements AiChatService {
|
||||
}
|
||||
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());
|
||||
}
|
||||
log.info("finish事件包含sources: count={}(仅日志,不写入引用表)", sourcesNode.isArray() ? sourcesNode.size() : 0);
|
||||
}
|
||||
if (jsonNode.has("citations")) {
|
||||
JsonNode citationsNode = jsonNode.get("citations");
|
||||
@@ -487,13 +452,6 @@ public class AiChatServiceImpl implements AiChatService {
|
||||
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={}",
|
||||
@@ -533,13 +491,6 @@ public class AiChatServiceImpl implements AiChatService {
|
||||
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 {
|
||||
@@ -588,67 +539,30 @@ public class AiChatServiceImpl implements AiChatService {
|
||||
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");
|
||||
}
|
||||
|
||||
imageNode.put("url", "/api/image/" + imageId + "/data");
|
||||
imageList.add(imageNode);
|
||||
}
|
||||
imagesJson = objectMapper.writeValueAsString(imageList);
|
||||
}
|
||||
|
||||
String sourcesJson = null;
|
||||
if (!referenceList[0].isEmpty()) {
|
||||
java.util.Map<String, Integer> docCountMap = new java.util.HashMap<>();
|
||||
java.util.Map<String, String> docTypeMap = new java.util.HashMap<>();
|
||||
java.util.Map<String, java.util.List<Integer>> 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<Integer>()).add(ref.getPage());
|
||||
}
|
||||
}
|
||||
|
||||
java.util.List<ObjectNode> sourcesList = new java.util.ArrayList<>();
|
||||
for (java.util.Map.Entry<String, Integer> 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<Integer> 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);
|
||||
chatSessionId[0], request.getMessage(), finalAnswer, kbPathsJson, "rag", 1, durationMs, imagesJson);
|
||||
|
||||
if (savedAssistantMessage[0] != null) {
|
||||
log.info("助手消息已保存: messageId={}, chatSessionId={}", savedAssistantMessage[0].getId(), chatSessionId[0]);
|
||||
|
||||
if (!referenceList[0].isEmpty()) {
|
||||
for (ChatReference ref : referenceList[0]) {
|
||||
try {
|
||||
String previewContent = fetchPreviewFromRagService(
|
||||
ref.getCollection(), ref.getDocName(), ref.getChunkIndex());
|
||||
if (previewContent != null && !previewContent.isEmpty()) {
|
||||
ref.setPreview(previewContent);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("填充preview失败: chunkId={}, error={}", ref.getChunkId(), ex.getMessage());
|
||||
}
|
||||
}
|
||||
chatService.saveReferences(savedAssistantMessage[0].getId(), referenceList[0]);
|
||||
log.info("引用信息已保存: count={}, messageId={}", referenceList[0].size(), savedAssistantMessage[0].getId());
|
||||
}
|
||||
@@ -686,7 +600,9 @@ public class AiChatServiceImpl implements AiChatService {
|
||||
for (ChatReference ref : referenceList[0]) {
|
||||
ReferenceResponse response = new ReferenceResponse();
|
||||
response.setChunkId(ref.getChunkId());
|
||||
response.setChunkIndex(ref.getChunkIndex());
|
||||
response.setSource(ref.getDocName());
|
||||
response.setCollection(ref.getCollection());
|
||||
response.setDocType(ref.getDocType());
|
||||
response.setPage(ref.getPage());
|
||||
response.setPageEnd(ref.getPageEnd());
|
||||
@@ -709,7 +625,9 @@ public class AiChatServiceImpl implements AiChatService {
|
||||
for (ChatReference ref : citationList[0]) {
|
||||
ReferenceResponse response = new ReferenceResponse();
|
||||
response.setChunkId(ref.getChunkId());
|
||||
response.setChunkIndex(ref.getChunkIndex());
|
||||
response.setSource(ref.getDocName());
|
||||
response.setCollection(ref.getCollection());
|
||||
response.setDocType(ref.getDocType());
|
||||
response.setPage(ref.getPage());
|
||||
response.setPageEnd(ref.getPageEnd());
|
||||
@@ -768,13 +686,7 @@ public class AiChatServiceImpl implements AiChatService {
|
||||
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");
|
||||
}
|
||||
imageNode.put("url", "/api/image/" + imageId + "/data");
|
||||
imageList.add(imageNode);
|
||||
}
|
||||
}
|
||||
@@ -835,7 +747,12 @@ public class AiChatServiceImpl implements AiChatService {
|
||||
private ChatReference parseSourceToReference(JsonNode sourceItem) {
|
||||
ChatReference ref = new ChatReference();
|
||||
ref.setChunkId(sourceItem.has("chunk_id") ? sourceItem.get("chunk_id").asText() : "");
|
||||
|
||||
JsonNode chunkIndexNode = sourceItem.path("chunk_index");
|
||||
ref.setChunkIndex(!chunkIndexNode.isNull() ? chunkIndexNode.asInt() : null);
|
||||
|
||||
ref.setDocName(sourceItem.has("source") ? sourceItem.get("source").asText() : "");
|
||||
ref.setCollection(sourceItem.has("collection") ? sourceItem.get("collection").asText() : null);
|
||||
|
||||
JsonNode pageNode = sourceItem.path("page");
|
||||
ref.setPage(!pageNode.isNull() ? pageNode.asInt() : null);
|
||||
@@ -855,6 +772,10 @@ public class AiChatServiceImpl implements AiChatService {
|
||||
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("bbox")) {
|
||||
ref.setBbox(sourceItem.get("bbox").toString());
|
||||
}
|
||||
ref.setBboxMode(sourceItem.has("bbox_mode") ? sourceItem.get("bbox_mode").asText() : null);
|
||||
if (sourceItem.has("score")) {
|
||||
JsonNode scoreNode = sourceItem.get("score");
|
||||
if (scoreNode.isNumber()) {
|
||||
@@ -867,10 +788,18 @@ public class AiChatServiceImpl implements AiChatService {
|
||||
private ChatReference parseCitationToReference(JsonNode citationItem) {
|
||||
ChatReference ref = new ChatReference();
|
||||
ref.setChunkId(citationItem.has("chunk_id") ? citationItem.get("chunk_id").asText() : "");
|
||||
|
||||
JsonNode chunkIndexNode = citationItem.path("chunk_index");
|
||||
ref.setChunkIndex(!chunkIndexNode.isNull() ? chunkIndexNode.asInt() : null);
|
||||
|
||||
ref.setDocName(citationItem.has("source") ? citationItem.get("source").asText() : "");
|
||||
ref.setCollection(citationItem.has("collection") ? citationItem.get("collection").asText() : null);
|
||||
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);
|
||||
ref.setExcerpt(citationItem.has("content") ? citationItem.get("content").asText() :
|
||||
(citationItem.has("preview") ? citationItem.get("preview").asText() :
|
||||
(citationItem.has("excerpt") ? citationItem.get("excerpt").asText() : "")));
|
||||
|
||||
JsonNode pageNode = citationItem.path("page");
|
||||
ref.setPage(!pageNode.isNull() ? pageNode.asInt() : null);
|
||||
@@ -885,7 +814,7 @@ public class AiChatServiceImpl implements AiChatService {
|
||||
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() : "");
|
||||
ref.setChunkType(citationItem.has("chunk_type") ? citationItem.get("chunk_type").asText() : null);
|
||||
if (citationItem.has("score")) {
|
||||
JsonNode scoreNode = citationItem.get("score");
|
||||
if (scoreNode.isNumber()) {
|
||||
@@ -895,70 +824,38 @@ public class AiChatServiceImpl implements AiChatService {
|
||||
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()) {
|
||||
private String fetchPreviewFromRagService(String collection, String docName, Integer chunkIndex) {
|
||||
if (collection == null || docName == null || chunkIndex == null) {
|
||||
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());
|
||||
String previewUrl = aiApiConfig.getFullUrl(aiApiConfig.getDocument().getPreview());
|
||||
previewUrl = previewUrl.replace("{path}", collection + "/" + docName)
|
||||
+ "?chunk_index=" + chunkIndex + "&context=0";
|
||||
log.debug("调用RAG预览接口: {}", previewUrl);
|
||||
|
||||
String response = restTemplate.getForObject(previewUrl, String.class);
|
||||
if (response == null) {
|
||||
return "预览接口返回空";
|
||||
}
|
||||
|
||||
JsonNode root = objectMapper.readTree(response);
|
||||
if (root.has("chunks") && root.get("chunks").isArray()) {
|
||||
JsonNode chunksNode = root.get("chunks");
|
||||
for (JsonNode chunk : chunksNode) {
|
||||
if (chunk.has("is_target") && chunk.get("is_target").asBoolean(false)) {
|
||||
if (chunk.has("document")) {
|
||||
return chunk.get("document").asText();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
log.warn("调用RAG预览接口失败: collection={}, docName={}, chunkIndex={}, error={}",
|
||||
collection, docName, chunkIndex, e.getMessage());
|
||||
return "预览获取失败: " + e.getMessage();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,27 +9,16 @@ 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.entity.CollectionFile;
|
||||
import top.tqx.demo_1.mapper.ChatReferenceMapper;
|
||||
import top.tqx.demo_1.mapper.ChatSessionMapper;
|
||||
import top.tqx.demo_1.mapper.CollectionFileMapper;
|
||||
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
|
||||
@@ -46,17 +35,7 @@ public class ChatServiceImpl implements ChatService {
|
||||
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/";
|
||||
private CollectionFileMapper collectionFileMapper;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -116,7 +95,7 @@ public class ChatServiceImpl implements ChatService {
|
||||
|
||||
@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) {
|
||||
public ChatMessage saveAssistantMessage(Long sessionId, String content, String answer, String kbPaths, String messageType, Integer isFinished, Long durationMs, String images) {
|
||||
if (content == null || content.isEmpty()) {
|
||||
log.warn("助手消息内容为空,跳过保存");
|
||||
return null;
|
||||
@@ -127,12 +106,6 @@ public class ChatServiceImpl implements ChatService {
|
||||
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");
|
||||
@@ -143,10 +116,9 @@ public class ChatServiceImpl implements ChatService {
|
||||
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");
|
||||
log.info("保存助手消息: messageId={}, sessionId={}, type={}, finished={}, duration={}ms, images={}", message.getId(), sessionId, messageType, isFinished, durationMs, images != null ? "[" + images.length() + " chars]" : "null");
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -156,15 +128,35 @@ public class ChatServiceImpl implements ChatService {
|
||||
ChatReference reference = new ChatReference();
|
||||
reference.setMessageId(messageId);
|
||||
reference.setChunkId(chunkId);
|
||||
reference.setDocPath(docPath);
|
||||
|
||||
// 通过 collection 和 docName 查找 collection_file 的 id
|
||||
String collection = extractCollectionFromDocPath(docPath);
|
||||
if (collection != null && docName != null && !docName.isEmpty()) {
|
||||
CollectionFile collectionFile = collectionFileMapper.findByCollectionAndDocName(collection, docName);
|
||||
if (collectionFile != null) {
|
||||
reference.setFileId(collectionFile.getId());
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
log.info("保存引用: referenceId={}, messageId={}, docName={}, fileId={}", reference.getId(), messageId, docName, reference.getFileId());
|
||||
return reference;
|
||||
}
|
||||
|
||||
private String extractCollectionFromDocPath(String docPath) {
|
||||
if (docPath == null || docPath.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
int index = docPath.indexOf("/");
|
||||
if (index > 0) {
|
||||
return docPath.substring(0, index);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -173,6 +165,17 @@ public class ChatServiceImpl implements ChatService {
|
||||
for (ChatReference ref : references) {
|
||||
ref.setMessageId(messageId);
|
||||
ref.setCreateTime(LocalDateTime.now());
|
||||
|
||||
// 通过 collection 和 docName 查找 collection_file 的 id
|
||||
String collection = ref.getCollection();
|
||||
String docName = ref.getDocName();
|
||||
if (collection != null && !collection.isEmpty() && docName != null && !docName.isEmpty()) {
|
||||
CollectionFile collectionFile = collectionFileMapper.findByCollectionAndDocName(collection, docName);
|
||||
if (collectionFile != null) {
|
||||
ref.setFileId(collectionFile.getId());
|
||||
}
|
||||
}
|
||||
|
||||
chatReferenceMapper.insert(ref);
|
||||
saved.add(ref);
|
||||
}
|
||||
@@ -190,365 +193,20 @@ public class ChatServiceImpl implements ChatService {
|
||||
|
||||
@Override
|
||||
public List<ChatMessage> getSessionMessages(String sessionId) {
|
||||
log.info("开始获取会话消息,sessionId={}", sessionId);
|
||||
|
||||
// 先通过 sessionId 查找会话,获取会话的 id
|
||||
LambdaQueryWrapper<ChatSession> 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<ChatMessage> messageWrapper = new LambdaQueryWrapper<>();
|
||||
messageWrapper.eq(ChatMessage::getConversationId, session.getId())
|
||||
.orderByAsc(ChatMessage::getCreateTime);
|
||||
List<ChatMessage> messages = chatMessageMapper.selectList(messageWrapper);
|
||||
|
||||
log.info("查询到 {} 条消息", messages.size());
|
||||
|
||||
// 为每条消息关联查询引用信息并处理图片URL
|
||||
for (ChatMessage message : messages) {
|
||||
// 如果是AI助手消息,关联查询引用信息
|
||||
if ("assistant".equals(message.getRole())) {
|
||||
List<ChatReference> 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<ChatReference> references) {
|
||||
log.info("开始从引用中提取图片,messageId={}", message.getId());
|
||||
|
||||
try {
|
||||
List<Map<String, Object>> imagesList = new ArrayList<>();
|
||||
|
||||
// 如果消息已经有图片数据,先解析出来
|
||||
if (message.getImages() != null && !message.getImages().isEmpty()) {
|
||||
imagesList = objectMapper.readValue(
|
||||
message.getImages(),
|
||||
new TypeReference<List<Map<String, Object>>>() {}
|
||||
);
|
||||
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<Image> foundImages = imageService.findImagesByReference(
|
||||
reference.getDocName(),
|
||||
reference.getPage(),
|
||||
reference.getChunkId()
|
||||
);
|
||||
|
||||
if (foundImages != null && !foundImages.isEmpty()) {
|
||||
log.info("从引用中找到 {} 张图片", foundImages.size());
|
||||
|
||||
for (Image image : foundImages) {
|
||||
Map<String, Object> 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<String, Object> 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<Map<String, Object>> images = objectMapper.readValue(
|
||||
message.getImages(),
|
||||
new TypeReference<List<Map<String, Object>>>() {});
|
||||
|
||||
log.info("解析到 {} 张图片", images.size());
|
||||
|
||||
boolean updated = false;
|
||||
for (int i = 0; i < images.size(); i++) {
|
||||
Map<String, Object> 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<ChatReference> references) {
|
||||
if (references == null || references.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取现有的sources
|
||||
List<Map<String, Object>> sourcesList = new ArrayList<>();
|
||||
if (message.getSources() != null && !message.getSources().isEmpty()) {
|
||||
sourcesList = objectMapper.readValue(
|
||||
message.getSources(),
|
||||
new TypeReference<List<Map<String, Object>>>() {});
|
||||
}
|
||||
|
||||
// 创建引用详情映射
|
||||
Map<String, Object> citationsMap = new HashMap<>();
|
||||
citationsMap.put("citations", references);
|
||||
|
||||
// 如果sources为空,创建一个默认的sources列表
|
||||
if (sourcesList.isEmpty()) {
|
||||
// 从引用中提取文档信息
|
||||
Map<String, Object> 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());
|
||||
}
|
||||
return chatMessageMapper.selectList(messageWrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -93,7 +93,7 @@ public class ExamPaperServiceImpl implements ExamPaperService {
|
||||
detail.setDifficulty(q.getDifficulty());
|
||||
detail.setDifficultyName(getDifficultyName(q.getDifficulty()));
|
||||
detail.setScore(q.getScore());
|
||||
detail.setDocumentName(q.getDocumentName());
|
||||
detail.setDocumentName(q.getExcludeStems());
|
||||
detail.setKnowledgeBasePath(q.getKnowledgeBasePath());
|
||||
|
||||
if (q.getContent() != null) {
|
||||
|
||||
@@ -12,8 +12,6 @@ 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;
|
||||
@@ -34,8 +32,6 @@ public class ExamRecordServiceImpl implements ExamRecordService {
|
||||
|
||||
@Autowired
|
||||
private UserAnswerMapper userAnswerMapper;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public List<ExamRecordResponse> getUserPapers(Long userId, String answerType) {
|
||||
@@ -177,29 +173,9 @@ public class ExamRecordServiceImpl implements ExamRecordService {
|
||||
.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<Question>().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());
|
||||
|
||||
@@ -22,6 +22,7 @@ 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.mapper.FileMapper;
|
||||
import top.tqx.demo_1.service.ExamService;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -55,6 +56,25 @@ public class ExamServiceImpl implements ExamService {
|
||||
|
||||
@Autowired
|
||||
private CollectionFileMapper collectionFileMapper;
|
||||
|
||||
@Autowired
|
||||
private FileMapper fileMapper;
|
||||
|
||||
private String findFilePathByFileId(Long fileId) {
|
||||
if (fileId == null) {
|
||||
return null;
|
||||
}
|
||||
List<CollectionFile> collectionFiles = collectionFileMapper.selectList(
|
||||
new LambdaQueryWrapper<CollectionFile>()
|
||||
.eq(CollectionFile::getFileId, fileId)
|
||||
.eq(CollectionFile::getStatus, 1));
|
||||
|
||||
if (!collectionFiles.isEmpty()) {
|
||||
CollectionFile cf = collectionFiles.get(0);
|
||||
return cf.getCollectionName() + "/" + cf.getDocName();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExamGenerateResponse generateQuestions(ExamGenerateRequest request) throws IOException {
|
||||
@@ -63,9 +83,18 @@ public class ExamServiceImpl implements ExamService {
|
||||
aiApiConfig.getFullUrl(aiApiConfig.getExam().getGenerateSmart()) :
|
||||
aiApiConfig.getFullUrl(aiApiConfig.getExam().getGenerate());
|
||||
|
||||
// 如果 filePath 为空,尝试通过 fileId 查询
|
||||
String filePath = request.getFilePath();
|
||||
if (filePath == null || filePath.isEmpty()) {
|
||||
if (request.getFileId() != null) {
|
||||
filePath = findFilePathByFileId(request.getFileId());
|
||||
log.info("通过 fileId 查询到 filePath: {}", filePath);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("开始生成题目");
|
||||
log.info("请求参数: requestId={}, fileId={}, filePath={}, questionTypes={}, difficulty={}",
|
||||
request.getRequestId(), request.getFileId(), request.getFilePath(),
|
||||
request.getRequestId(), request.getFileId(), filePath,
|
||||
request.getQuestionTypes(), request.getDifficulty());
|
||||
log.info("使用API: {}", useSmartApi ? "智能生成" : "指定题型");
|
||||
log.info("请求URL: {}", url);
|
||||
@@ -74,7 +103,35 @@ public class ExamServiceImpl implements ExamService {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
|
||||
String jsonResponse = postJsonRequest(url, objectMapper.writeValueAsString(request), 120000);
|
||||
// 构建发送给AI的请求体,exclude_stems由后端自动处理,不使用前端传入的值
|
||||
Map<String, Object> aiRequest = new HashMap<>();
|
||||
aiRequest.put("file_path", filePath);
|
||||
|
||||
// 提取 collection 名称(从 filePath 中提取,格式为 collection_name/doc_name)
|
||||
String collection = null;
|
||||
if (filePath != null && filePath.contains("/")) {
|
||||
collection = filePath.substring(0, filePath.indexOf("/"));
|
||||
}
|
||||
// 如果 request 中有 collectionName,优先使用
|
||||
if (request.getCollectionName() != null && !request.getCollectionName().isEmpty()) {
|
||||
collection = request.getCollectionName();
|
||||
}
|
||||
aiRequest.put("collection", collection);
|
||||
|
||||
aiRequest.put("difficulty", request.getDifficulty());
|
||||
if (request.getQuestionTypes() != null) {
|
||||
aiRequest.put("question_types", request.getQuestionTypes());
|
||||
}
|
||||
// 只在exclude_stems有值时才传入(后端自动填充的)
|
||||
if (request.getExcludeStems() != null && !request.getExcludeStems().isEmpty()) {
|
||||
aiRequest.put("exclude_stems", request.getExcludeStems());
|
||||
log.info("发送 exclude_stems 到AI服务,共 {} 条", request.getExcludeStems().size());
|
||||
}
|
||||
|
||||
// 打印实际发送的请求体
|
||||
String requestBody = objectMapper.writeValueAsString(aiRequest);
|
||||
log.info("发送给AI服务的请求体: {}", requestBody);
|
||||
String jsonResponse = postJsonRequest(url, requestBody, 120000);
|
||||
log.info("RAG服务响应: {}", jsonResponse);
|
||||
|
||||
ExamGenerateResponse response = parseExamGenerateResponse(jsonResponse);
|
||||
@@ -83,7 +140,7 @@ public class ExamServiceImpl implements ExamService {
|
||||
|
||||
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());
|
||||
int savedCount = saveQuestionsToDatabase(response.getQuestions(), request.getFileId(), request.getCollectionName(), response.getSourceChunksUsed(), request.getQuestionScores());
|
||||
log.info("成功保存 {} 道题目到数据库", savedCount);
|
||||
} else {
|
||||
log.warn("未生成任何题目,无需保存");
|
||||
@@ -104,6 +161,8 @@ public class ExamServiceImpl implements ExamService {
|
||||
request.getFileId(), request.getFilePath(), request.getQuestionTypes());
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("requestId", request.getRequestId());
|
||||
result.put("fileId", request.getFileId());
|
||||
|
||||
try {
|
||||
String collectionName = "test1";
|
||||
@@ -111,27 +170,49 @@ public class ExamServiceImpl implements ExamService {
|
||||
String documentName = "1.docx";
|
||||
|
||||
if (request.getFileId() != null) {
|
||||
log.info("根据 fileId 查询 collection_file 表: fileId={}", request.getFileId());
|
||||
|
||||
// 真正从数据库查询 collection_file 表
|
||||
log.info("根据 fileId 查询文件信息: fileId={}", request.getFileId());
|
||||
|
||||
// 先检查 File 表中的文件状态
|
||||
top.tqx.demo_1.entity.File file = fileMapper.selectById(request.getFileId());
|
||||
if (file == null) {
|
||||
log.error("文件不存在: fileId={}", request.getFileId());
|
||||
result.put("fileMatchStatus", "FILE_NOT_FOUND");
|
||||
result.put("success", false);
|
||||
result.put("message", "文件不存在: fileId=" + request.getFileId());
|
||||
return result;
|
||||
}
|
||||
|
||||
// 检查文件状态,status=0 表示已删除
|
||||
if (file.getStatus() != null && file.getStatus() == 0) {
|
||||
log.error("文件已被删除: fileId={}, fileName={}", request.getFileId(), file.getFileName());
|
||||
result.put("fileMatchStatus", "FILE_DELETED");
|
||||
result.put("success", false);
|
||||
result.put("message", "文件已被删除: fileId=" + request.getFileId());
|
||||
return result;
|
||||
}
|
||||
|
||||
// 检查 collection_file 表中是否存在该文件的记录(即是否已完成向量化)
|
||||
List<CollectionFile> collectionFiles = collectionFileMapper.selectList(
|
||||
new LambdaQueryWrapper<CollectionFile>()
|
||||
.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={}",
|
||||
log.info("查询到文件信息: collectionName={}, filePath={}, documentName={}",
|
||||
collectionName, filePath, documentName);
|
||||
} else {
|
||||
log.warn("未在 collection_file 表中找到 fileId={} 的记录,使用默认值", request.getFileId());
|
||||
log.error("未在 collection_file 表中找到 fileId={} 的有效记录,文件可能未完成向量化", request.getFileId());
|
||||
result.put("fileMatchStatus", "NOT_VECTORIZED");
|
||||
result.put("success", false);
|
||||
result.put("message", "文件未完成向量化处理,请先上传并等待向量化完成: fileId=" + request.getFileId());
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
// 如果 request 中已经提供了,优先使用 request 中的值
|
||||
if (request.getCollectionName() != null) {
|
||||
collectionName = request.getCollectionName();
|
||||
@@ -140,7 +221,7 @@ public class ExamServiceImpl implements ExamService {
|
||||
filePath = request.getFilePath();
|
||||
documentName = request.getFilePath();
|
||||
}
|
||||
|
||||
|
||||
// 更新 request 对象,供后续调用使用
|
||||
request.setCollection(collectionName);
|
||||
request.setFilePath(filePath);
|
||||
@@ -151,17 +232,16 @@ public class ExamServiceImpl implements ExamService {
|
||||
collectionName = request.getCollectionName();
|
||||
}
|
||||
documentName = filePath;
|
||||
|
||||
|
||||
// 更新 request 对象,供后续调用使用
|
||||
request.setCollection(collectionName);
|
||||
} else {
|
||||
result.put("fileMatchStatus", "PARAM_MISSING");
|
||||
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);
|
||||
@@ -182,6 +262,11 @@ public class ExamServiceImpl implements ExamService {
|
||||
generateParams.put("question_types", request.getQuestionTypes());
|
||||
}
|
||||
|
||||
if (request.getExcludeStems() != null && !request.getExcludeStems().isEmpty()) {
|
||||
generateParams.put("exclude_stems", request.getExcludeStems());
|
||||
log.info("已添加 exclude_stems 参数,共 {} 条", request.getExcludeStems().size());
|
||||
}
|
||||
|
||||
result.put("aiRequestParams", generateParams);
|
||||
result.put("aiApiPath", apiPath);
|
||||
result.put("success", true);
|
||||
@@ -191,6 +276,7 @@ public class ExamServiceImpl implements ExamService {
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("generateQuestionsWithFileId 异常: {}", e.getMessage(), e);
|
||||
result.put("fileMatchStatus", "INTERNAL_ERROR");
|
||||
result.put("success", false);
|
||||
result.put("message", "处理失败: " + e.getMessage());
|
||||
return result;
|
||||
@@ -400,7 +486,7 @@ public class ExamServiceImpl implements ExamService {
|
||||
try {
|
||||
ExamGradeRequest.QuestionContent questionContent = objectMapper.treeToValue(
|
||||
question.getContent(), ExamGradeRequest.QuestionContent.class);
|
||||
gradeItem.setQuestionContent(questionContent);
|
||||
gradeItem.setContent(questionContent);
|
||||
} catch (Exception e) {
|
||||
log.warn("转换题目内容失败", e);
|
||||
}
|
||||
@@ -413,12 +499,12 @@ public class ExamServiceImpl implements ExamService {
|
||||
gradeItem.setMaxScore(new BigDecimal(5));
|
||||
}
|
||||
|
||||
// 确保questionContent不为null,避免AI服务报错
|
||||
if (gradeItem.getQuestionContent() == null) {
|
||||
// 确保content不为null,避免AI服务报错
|
||||
if (gradeItem.getContent() == null) {
|
||||
ExamGradeRequest.QuestionContent questionContent = new ExamGradeRequest.QuestionContent();
|
||||
questionContent.setStem("");
|
||||
questionContent.setAnswer("");
|
||||
gradeItem.setQuestionContent(questionContent);
|
||||
gradeItem.setContent(questionContent);
|
||||
log.warn("题目内容为空,已设置默认值: questionId={}", saveItem.getQuestionId());
|
||||
}
|
||||
|
||||
@@ -530,24 +616,7 @@ public class ExamServiceImpl implements ExamService {
|
||||
}
|
||||
|
||||
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.setIsCorrect(Boolean.TRUE.equals(isCorrect) ? 1 : 0);
|
||||
userAnswer.setGradingType("ai");
|
||||
userAnswer.setFeedback(result.getFeedback());
|
||||
userAnswer.setCreatedAt(LocalDateTime.now());
|
||||
@@ -732,7 +801,7 @@ public class ExamServiceImpl implements ExamService {
|
||||
paperQuestion.setContent(question.getContent());
|
||||
paperQuestion.setScore(question.getScore() != null ? question.getScore() : new BigDecimal(5));
|
||||
paperQuestion.setDifficulty(question.getDifficulty());
|
||||
paperQuestion.setDocumentName(question.getDocumentName());
|
||||
paperQuestion.setExcludeStems(question.getExcludeStems());
|
||||
paperQuestion.setKnowledgeBasePath(question.getKnowledgeBasePath());
|
||||
selectedQuestions.add(paperQuestion);
|
||||
}
|
||||
@@ -859,6 +928,14 @@ public class ExamServiceImpl implements ExamService {
|
||||
if (resultNode.has("max_score")) {
|
||||
result.setMaxScore(resultNode.get("max_score").decimalValue());
|
||||
}
|
||||
|
||||
// 新增:解析 grading_status
|
||||
if (resultNode.has("grading_status")) {
|
||||
result.setGradingStatus(resultNode.get("grading_status").asText());
|
||||
log.info("读取到 grading_status: {}", result.getGradingStatus());
|
||||
}
|
||||
|
||||
// 先从顶层读取 correct(兼容旧格式)
|
||||
if (resultNode.has("correct")) {
|
||||
result.setCorrect(resultNode.get("correct").asBoolean());
|
||||
}
|
||||
@@ -866,6 +943,51 @@ public class ExamServiceImpl implements ExamService {
|
||||
result.setIsCorrect(resultNode.get("is_correct").asBoolean());
|
||||
}
|
||||
|
||||
// 解析 details(新格式)
|
||||
if (resultNode.has("details")) {
|
||||
com.fasterxml.jackson.databind.JsonNode detailsNode = resultNode.get("details");
|
||||
ExamGradeResponse.GradeResult.DetailInfo details = new ExamGradeResponse.GradeResult.DetailInfo();
|
||||
|
||||
if (detailsNode.has("correct")) {
|
||||
details.setCorrect(detailsNode.get("correct").asBoolean());
|
||||
// 如果 details 中有 correct,覆盖顶层的值(新格式优先)
|
||||
if (result.getCorrect() == null) {
|
||||
result.setCorrect(details.getCorrect());
|
||||
}
|
||||
}
|
||||
if (detailsNode.has("student_answer")) {
|
||||
details.setStudentAnswer(detailsNode.get("student_answer"));
|
||||
}
|
||||
if (detailsNode.has("correct_answer")) {
|
||||
details.setCorrectAnswer(detailsNode.get("correct_answer"));
|
||||
}
|
||||
if (detailsNode.has("feedback")) {
|
||||
details.setFeedback(detailsNode.get("feedback").asText());
|
||||
}
|
||||
if (detailsNode.has("total_blanks")) {
|
||||
details.setTotalBlanks(detailsNode.get("total_blanks").asInt());
|
||||
}
|
||||
if (detailsNode.has("correct_blanks")) {
|
||||
details.setCorrectBlanks(detailsNode.get("correct_blanks").asInt());
|
||||
}
|
||||
if (detailsNode.has("error")) {
|
||||
details.setError(detailsNode.get("error").asText());
|
||||
}
|
||||
if (detailsNode.has("warnings")) {
|
||||
com.fasterxml.jackson.databind.JsonNode warningsNode = detailsNode.get("warnings");
|
||||
if (warningsNode.isArray()) {
|
||||
List<String> warnings = new ArrayList<>();
|
||||
for (int j = 0; j < warningsNode.size(); j++) {
|
||||
warnings.add(warningsNode.get(j).asText());
|
||||
}
|
||||
details.setWarnings(warnings);
|
||||
}
|
||||
}
|
||||
|
||||
result.setDetails(details);
|
||||
log.info("已解析 details 结构");
|
||||
}
|
||||
|
||||
results.add(result);
|
||||
}
|
||||
|
||||
@@ -893,7 +1015,30 @@ public class ExamServiceImpl implements ExamService {
|
||||
StringBuilder feedbackBuilder = new StringBuilder();
|
||||
|
||||
String questionType = result.getQuestionType();
|
||||
log.info("处理第 {} 题, 类型: {}", i + 1, questionType);
|
||||
String gradingStatus = result.getGradingStatus();
|
||||
log.info("处理第 {} 题, 类型: {}, grading_status: {}", i + 1, questionType, gradingStatus);
|
||||
|
||||
// 处理评分失败的情况
|
||||
if ("failed".equals(gradingStatus)) {
|
||||
String errorMsg = "评分失败";
|
||||
if (result.getDetails() != null && result.getDetails().getError() != null) {
|
||||
errorMsg = result.getDetails().getError();
|
||||
} else if (resultNode.has("details") && resultNode.get("details").has("error")) {
|
||||
errorMsg = resultNode.get("details").get("error").asText();
|
||||
}
|
||||
result.setFeedback("评分失败: " + errorMsg);
|
||||
log.warn("第 {} 题评分失败: {}", i + 1, errorMsg);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 新格式:从 details 中获取 feedback
|
||||
if (result.getDetails() != null && result.getDetails().getFeedback() != null) {
|
||||
result.setFeedback(result.getDetails().getFeedback());
|
||||
log.info("从 details 中获取 feedback,长度: {}", result.getFeedback().length());
|
||||
continue;
|
||||
}
|
||||
|
||||
// 旧格式兼容:从 resultNode 中获取 feedback
|
||||
|
||||
// 主观题处理
|
||||
if ("subjective".equals(questionType) && resultNode.has("details")) {
|
||||
@@ -991,7 +1136,7 @@ public class ExamServiceImpl implements ExamService {
|
||||
}
|
||||
}
|
||||
|
||||
private int saveQuestionsToDatabase(List<ExamGenerateRequest.Question> questions, Long fileId, String collectionName, Integer sourceChunksUsed) {
|
||||
private int saveQuestionsToDatabase(List<ExamGenerateRequest.Question> questions, Long fileId, String collectionName, Integer sourceChunksUsed, Map<String, BigDecimal> questionScores) {
|
||||
int savedCount = 0;
|
||||
for (ExamGenerateRequest.Question q : questions) {
|
||||
try {
|
||||
@@ -999,13 +1144,13 @@ public class ExamServiceImpl implements ExamService {
|
||||
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.setScore(calculateScore(q.getQuestionType(), q.getScore(), questionScores));
|
||||
question.setStatus("pending");
|
||||
question.setDocumentId(fileId != null ? fileId.toString() : null);
|
||||
question.setDocumentName(q.getContent() != null ?
|
||||
question.setExcludeStems(q.getContent() != null ?
|
||||
(q.getContent().getStem() != null ?
|
||||
(q.getContent().getStem().length() > 255 ?
|
||||
q.getContent().getStem().substring(0, 255) : q.getContent().getStem()) : null) : null);
|
||||
(q.getContent().getStem().length() > 500 ?
|
||||
q.getContent().getStem().substring(0, 500) : q.getContent().getStem()) : null) : null);
|
||||
question.setKnowledgeBasePath(collectionName);
|
||||
question.setSourceContext(null);
|
||||
question.setSourceChunksUsed(sourceChunksUsed != null ? sourceChunksUsed : 0);
|
||||
@@ -1095,5 +1240,45 @@ public class ExamServiceImpl implements ExamService {
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private BigDecimal calculateScore(String questionType, BigDecimal aiScore, Map<String, BigDecimal> 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("10.00");
|
||||
default:
|
||||
log.warn("未知的 questionType: {},使用默认分数 5.00", questionType);
|
||||
return new BigDecimal("5.00");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ public class FileServiceImpl extends ServiceImpl<FileMapper, File> implements Fi
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public FileVO uploadFile(MultipartFile file, Long userId, Long deptId, Integer userType, Long targetDeptId, Boolean isPublic) {
|
||||
public FileVO uploadFile(MultipartFile file, Long userId, Long deptId, Integer userType, Long targetDeptId, Boolean isPublic, Boolean autoVectorize) {
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
if (originalFilename == null || originalFilename.isEmpty()) {
|
||||
throw new RuntimeException("文件名不能为空");
|
||||
@@ -185,10 +185,10 @@ public class FileServiceImpl extends ServiceImpl<FileMapper, File> implements Fi
|
||||
throw new RuntimeException("文件记录保存到数据库失败");
|
||||
}
|
||||
|
||||
log.info("文件上传成功: fileName={}, userId={}, deptId={}, isPublic={}, category={}, auditStatus={}, path={}",
|
||||
originalFilename, userId, finalDeptId, isPublicValue, fileCategory, auditStatus, relativePath);
|
||||
log.info("文件上传成功: fileName={}, userId={}, deptId={}, isPublic={}, category={}, auditStatus={}, path={}, autoVectorize={}",
|
||||
originalFilename, userId, finalDeptId, isPublicValue, fileCategory, auditStatus, relativePath, autoVectorize);
|
||||
|
||||
if (!needAudit) {
|
||||
if (!needAudit && autoVectorize != null && autoVectorize) {
|
||||
String collectionName = determineCollectionName(userType, finalDeptId, isPublic);
|
||||
if (collectionName != null) {
|
||||
checkAndDeleteOldVectorization(originalFilename, finalDeptId, collectionName);
|
||||
@@ -199,8 +199,10 @@ public class FileServiceImpl extends ServiceImpl<FileMapper, File> implements Fi
|
||||
log.warn("无法确定collection名称,跳过向量化: fileId={}, userType={}, deptId={}, isPublic={}",
|
||||
fileEntity.getId(), userType, finalDeptId, isPublic);
|
||||
}
|
||||
} else {
|
||||
} else if (needAudit) {
|
||||
log.info("文件需要审核,暂不向量化: fileId={}", fileEntity.getId());
|
||||
} else {
|
||||
log.info("文件上传完成,未触发自定向量化(autoVectorize=false): fileId={}", fileEntity.getId());
|
||||
}
|
||||
|
||||
return convertToVO(fileEntity);
|
||||
|
||||
@@ -3,7 +3,6 @@ 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;
|
||||
@@ -40,12 +39,6 @@ public class ImageServiceImpl implements ImageService {
|
||||
@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());
|
||||
@@ -107,17 +100,16 @@ public class ImageServiceImpl implements ImageService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试从本地获取图片
|
||||
* @param imageId 图片ID
|
||||
* @return 图片字节数组,如果未找到返回null
|
||||
*/
|
||||
private byte[] tryGetLocalImage(String imageId) {
|
||||
@Override
|
||||
public byte[] getImage(String imageId) {
|
||||
log.info("获取图片: imageId={}", imageId);
|
||||
|
||||
// 1. 首先尝试从数据库查找本地路径
|
||||
try {
|
||||
Image imageRecord = getImageByImageId(imageId);
|
||||
if (imageRecord != null && imageRecord.getPath() != null && !imageRecord.getPath().isEmpty()) {
|
||||
String dbPath = imageRecord.getPath();
|
||||
log.info("尝试从本地路径读取图片: imageId={}, path={}", imageId, dbPath);
|
||||
log.info("尝试从本地路径读取图片: path={}", dbPath);
|
||||
|
||||
// 方案1:如果数据库路径是完整路径且文件存在,直接使用
|
||||
Path fullPath = Paths.get(dbPath);
|
||||
@@ -128,16 +120,7 @@ public class ImageServiceImpl implements ImageService {
|
||||
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:兼容旧版代码,尝试其他可能的路径
|
||||
// 方案2:如果数据库路径是相对路径,尝试多种可能的完整路径
|
||||
String filename = dbPath.startsWith("/img/") ? dbPath.substring(5) : dbPath;
|
||||
String[] possibleDirs = {
|
||||
"./img",
|
||||
@@ -147,11 +130,11 @@ public class ImageServiceImpl implements ImageService {
|
||||
};
|
||||
|
||||
for (String dir : possibleDirs) {
|
||||
Path backupPath = Paths.get(dir, filename);
|
||||
if (Files.exists(backupPath)) {
|
||||
byte[] imageBytes = Files.readAllBytes(backupPath);
|
||||
Path tryPath = Paths.get(dir, filename);
|
||||
if (Files.exists(tryPath)) {
|
||||
byte[] imageBytes = Files.readAllBytes(tryPath);
|
||||
log.info("从本地读取图片成功(遍历目录): imageId={}, path={}, size={} bytes",
|
||||
imageId, backupPath, imageBytes.length);
|
||||
imageId, tryPath, imageBytes.length);
|
||||
return imageBytes;
|
||||
}
|
||||
}
|
||||
@@ -159,48 +142,10 @@ public class ImageServiceImpl implements ImageService {
|
||||
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;
|
||||
log.warn("从本地读取图片失败: {}", e.getMessage());
|
||||
}
|
||||
|
||||
// 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服务获取
|
||||
// 2. 如果本地没有,从AI服务获取
|
||||
String url = aiApiConfig.getFullUrl(aiApiConfig.getImages().getImage());
|
||||
|
||||
// 去掉可能的文件后缀
|
||||
@@ -222,10 +167,8 @@ public class ImageServiceImpl implements ImageService {
|
||||
return imageBytes;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("调用RAG服务获取图片失败: imageId={}, error={}", imageId, e.getMessage());
|
||||
// 返回一个占位符或者空数组,而不是抛出异常,避免影响整个页面加载
|
||||
log.warn("返回空图片数据作为回退: imageId={}", imageId);
|
||||
return new byte[0];
|
||||
log.error("调用RAG服务获取图片失败: {}", e.getMessage(), e);
|
||||
throw new RuntimeException("获取图片失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,19 +206,12 @@ public class ImageServiceImpl implements ImageService {
|
||||
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());
|
||||
@@ -297,18 +233,9 @@ public class ImageServiceImpl implements ImageService {
|
||||
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());
|
||||
}
|
||||
@@ -414,8 +341,6 @@ public class ImageServiceImpl implements ImageService {
|
||||
|
||||
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);
|
||||
@@ -475,12 +400,8 @@ public class ImageServiceImpl implements ImageService {
|
||||
|
||||
log.error("[{}] [{}] [数据转换阶段] 开始 - 构建ImageCreateRequest", java.time.LocalDateTime.now(), operationId);
|
||||
ImageCreateRequest request = new ImageCreateRequest();
|
||||
request.setImageId(imageId); // 数据库保存原始ID
|
||||
request.setImageId(imageId);
|
||||
|
||||
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());
|
||||
@@ -494,14 +415,6 @@ public class ImageServiceImpl implements ImageService {
|
||||
}
|
||||
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());
|
||||
@@ -511,11 +424,6 @@ public class ImageServiceImpl implements ImageService {
|
||||
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) {
|
||||
@@ -529,24 +437,11 @@ public class ImageServiceImpl implements ImageService {
|
||||
}
|
||||
log.info("[{}] [{}] [数据转换阶段] 成功 - ImageCreateRequest构建完成", java.time.LocalDateTime.now(), operationId);
|
||||
|
||||
log.error("[{}] [{}] [图片信息获取阶段] 开始 - 调用AI服务获取图片元信息, finalImageId={}", java.time.LocalDateTime.now(), operationId, finalImageId);
|
||||
log.error("[{}] [{}] [图片信息获取阶段] 开始 - 调用AI服务获取图片元信息, finalImageId={}", java.time.LocalDateTime.now(), operationId, imageId);
|
||||
Map<String, Object> 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());
|
||||
}
|
||||
log.error("[{}] [{}] [图片信息获取阶段] 成功 - 获取到图片信息, metadata={}",
|
||||
java.time.LocalDateTime.now(), operationId, aiImageInfo);
|
||||
} else {
|
||||
log.warn("[{}] [{}] [图片信息获取阶段] 警告 - 未能获取图片元信息, imageId={}",
|
||||
java.time.LocalDateTime.now(), operationId, imageId);
|
||||
@@ -562,12 +457,7 @@ public class ImageServiceImpl implements ImageService {
|
||||
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());
|
||||
}
|
||||
log.error("[{}] [{}] [本地保存阶段] 成功 - 图片保存到本地, path={}, dataLength={}", java.time.LocalDateTime.now(), operationId, localPath, imageData.length);
|
||||
} else {
|
||||
log.error("[{}] [{}] [本地保存阶段] 警告 - 未能保存图片到本地, 将继续保存数据库记录", java.time.LocalDateTime.now(), operationId);
|
||||
}
|
||||
@@ -576,14 +466,13 @@ public class ImageServiceImpl implements ImageService {
|
||||
java.time.LocalDateTime.now(), operationId, imageId);
|
||||
}
|
||||
|
||||
log.error("[{}] [{}] [数据库写入前] 开始 - 准备写入数据库, imageId={}, request={}",
|
||||
java.time.LocalDateTime.now(), operationId, imageId,
|
||||
request.getImageId() + "," + request.getPath() + "," + request.getFormat());
|
||||
log.error("[{}] [{}] [数据库写入前] 开始 - 准备写入数据库, imageId={}, path={}",
|
||||
java.time.LocalDateTime.now(), operationId, imageId, request.getPath());
|
||||
|
||||
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());
|
||||
log.error("[{}] [{}] [数据库写入后] 成功 - 图片记录已保存到数据库, imageId={}, dbId={}, path={}",
|
||||
java.time.LocalDateTime.now(), operationId, imageId, image.getId(), image.getPath());
|
||||
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
log.error("[{}] [{}] ========== 图片保存流程成功完成, 耗时={}ms ==========",
|
||||
@@ -602,134 +491,87 @@ public class ImageServiceImpl implements ImageService {
|
||||
|
||||
private String saveImageToLocal(String imageId, byte[] imageData) {
|
||||
try {
|
||||
log.debug("[本地保存] 开始 - 准备保存图片到本地, imageId={}, dataSize={} bytes",
|
||||
imageId, imageData.length);
|
||||
log.debug("[{}] [本地保存] 开始 - 准备保存图片到本地, imageId={}, dataSize={} bytes",
|
||||
java.time.LocalDateTime.now(), imageId, imageData.length);
|
||||
|
||||
// 使用系统临时目录或用户目录,避免权限问题
|
||||
Path imgDir = getImageStorageDir();
|
||||
log.debug("[{}] [本地保存] 检查目录是否存在: path={}", java.time.LocalDateTime.now(), imgDir.toAbsolutePath());
|
||||
|
||||
if (!Files.exists(imgDir)) {
|
||||
log.info("[{}] [本地保存] 创建图片目录: {}", java.time.LocalDateTime.now(), imgDir.toAbsolutePath());
|
||||
Files.createDirectories(imgDir);
|
||||
}
|
||||
|
||||
String filename = imageId;
|
||||
if (!filename.contains(".")) {
|
||||
filename = imageId + ".jpg";
|
||||
log.debug("[{}] [本地保存] 图片ID无扩展名, 添加.jpg后缀: {}", java.time.LocalDateTime.now(), filename);
|
||||
}
|
||||
Path filePath = imgDir.resolve(filename);
|
||||
log.debug("[{}] [本地保存] 构建文件路径: {}", java.time.LocalDateTime.now(), filePath.toAbsolutePath());
|
||||
|
||||
log.debug("[本地保存] 开始写入文件...");
|
||||
log.debug("[{}] [本地保存] 开始写入文件...", java.time.LocalDateTime.now());
|
||||
Files.write(filePath, imageData);
|
||||
|
||||
long fileSize = Files.size(filePath);
|
||||
log.info("[本地保存] 成功 - 图片已保存到本地, path={}, size={} bytes",
|
||||
filePath.toAbsolutePath(), fileSize);
|
||||
log.info("[{}] [本地保存] 成功 - 图片已保存到本地, path={}, size={} bytes",
|
||||
java.time.LocalDateTime.now(), filePath.toAbsolutePath(), fileSize);
|
||||
|
||||
// 构建相对路径(相对于 uploadPath),这样数据库里是 img/xxx.jpg
|
||||
Path basePath = Paths.get(uploadPath);
|
||||
String relativePath = basePath.relativize(filePath).toString().replace("\\", "/");
|
||||
log.debug("[本地保存] 返回相对路径: {}", relativePath);
|
||||
// 返回相对路径格式:img/filename.jpg,便于前端拼接 URL
|
||||
String relativePath = "img/" + filename;
|
||||
log.debug("[{}] [本地保存] 返回相对路径: {}", java.time.LocalDateTime.now(), relativePath);
|
||||
return relativePath;
|
||||
|
||||
} catch (java.io.IOException e) {
|
||||
log.error("[本地保存] 失败 - IO异常, imageId={}, error={}", imageId, e.getMessage(), e);
|
||||
log.error("[{}] [本地保存] 失败 - IO异常, imageId={}, error={}, stackTrace={}",
|
||||
java.time.LocalDateTime.now(), imageId, e.getMessage(),
|
||||
org.apache.commons.lang3.exception.ExceptionUtils.getStackTrace(e));
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
log.error("[本地保存] 失败 - 未知异常, imageId={}, error={}", imageId, e.getMessage(), e);
|
||||
log.error("[{}] [本地保存] 失败 - 未知异常, imageId={}, error={}, stackTrace={}",
|
||||
java.time.LocalDateTime.now(), imageId, e.getMessage(),
|
||||
org.apache.commons.lang3.exception.ExceptionUtils.getStackTrace(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");
|
||||
String[] possibleDirs = {
|
||||
"/app/img",
|
||||
"/app/uploads/img",
|
||||
"./img",
|
||||
System.getProperty("user.home") + "/img",
|
||||
System.getProperty("java.io.tmpdir") + "/img",
|
||||
"/tmp/img"
|
||||
};
|
||||
|
||||
for (String dir : possibleDirs) {
|
||||
Path path = Paths.get(dir);
|
||||
try {
|
||||
if (!Files.exists(fallbackPath)) {
|
||||
Files.createDirectories(fallbackPath);
|
||||
if (Files.exists(path)) {
|
||||
if (Files.isWritable(path)) {
|
||||
log.info("[{}] [存储目录] 找到可用目录: {}", java.time.LocalDateTime.now(), path.toAbsolutePath());
|
||||
return path;
|
||||
}
|
||||
} else {
|
||||
Files.createDirectories(path);
|
||||
log.info("[{}] [存储目录] 创建目录: {}", java.time.LocalDateTime.now(), path.toAbsolutePath());
|
||||
return path;
|
||||
}
|
||||
return fallbackPath;
|
||||
} catch (Exception ex) {
|
||||
log.error("[存储目录] 无法创建任何目录, error={}", ex.getMessage());
|
||||
return Paths.get("/tmp");
|
||||
} catch (Exception e) {
|
||||
log.warn("[{}] [存储目录检查] 目录不可用: {}, error={}", java.time.LocalDateTime.now(), dir, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据图片ID获取Nginx格式的URL
|
||||
* @param imageId 图片ID
|
||||
* @return Nginx格式的URL,如果找不到则返回null
|
||||
*/
|
||||
public String getNginxUrlByImageId(String imageId) {
|
||||
|
||||
Path fallbackPath = Paths.get(System.getProperty("java.io.tmpdir")).resolve("img");
|
||||
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;
|
||||
}
|
||||
}
|
||||
Files.createDirectories(fallbackPath);
|
||||
log.warn("[{}] [存储目录] 使用临时目录: {}", java.time.LocalDateTime.now(), fallbackPath.toAbsolutePath());
|
||||
return fallbackPath;
|
||||
} catch (Exception e) {
|
||||
log.warn("获取图片Nginx URL失败: imageId={}, error={}", imageId, e.getMessage());
|
||||
log.error("[{}] [存储目录] 无法创建任何目录, error={}", java.time.LocalDateTime.now(), e.getMessage());
|
||||
return Paths.get("/tmp");
|
||||
}
|
||||
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<Image> findImagesByReference(String docName, Integer page, String chunkId) {
|
||||
log.info("查找与引用关联的图片: docName={}, page={}, chunkId={}", docName, page, chunkId);
|
||||
|
||||
LambdaQueryWrapper<Image> queryWrapper = new LambdaQueryWrapper<>();
|
||||
|
||||
// 尝试通过 docName 和 page 查找
|
||||
if (docName != null && !docName.isEmpty()) {
|
||||
queryWrapper.eq(Image::getSource, docName);
|
||||
}
|
||||
if (page != null) {
|
||||
queryWrapper.eq(Image::getPage, page);
|
||||
}
|
||||
|
||||
List<Image> results = imageMapper.selectList(queryWrapper);
|
||||
log.info("通过 docName 和 page 找到 {} 张图片", results != null ? results.size() : 0);
|
||||
|
||||
return results != null ? results : new ArrayList<>();
|
||||
}
|
||||
}
|
||||
@@ -178,7 +178,10 @@ public class QuestionSaveServiceImpl implements QuestionSaveService {
|
||||
log.info("sources: {}", sourceTrace != null ? sourceTrace.getSources() : null);
|
||||
|
||||
if (sourceTrace != null) {
|
||||
entity.setDocumentName(sourceTrace.getDocumentName());
|
||||
// 存储题干内容,用于后续去重
|
||||
String stem = question.getContent() != null && question.getContent().getStem() != null ?
|
||||
question.getContent().getStem() : null;
|
||||
entity.setExcludeStems(stem);
|
||||
|
||||
if (sourceTrace.getChunkIds() != null && !sourceTrace.getChunkIds().isEmpty()) {
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@ package top.tqx.demo_1.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import top.tqx.demo_1.common.PageResult;
|
||||
import top.tqx.demo_1.dto.QuestionCreateRequest;
|
||||
import top.tqx.demo_1.dto.QuestionStatsRequest;
|
||||
@@ -29,12 +30,14 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> implements QuestionService {
|
||||
|
||||
@@ -102,6 +105,8 @@ public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> i
|
||||
Page<Question> page = new Page<>(pageNum, pageSize);
|
||||
LambdaQueryWrapper<Question> wrapper = new LambdaQueryWrapper<>();
|
||||
|
||||
wrapper.and(w -> w.isNull(Question::getIsDeleted).or().eq(Question::getIsDeleted, 0));
|
||||
|
||||
if (questionType != null && !questionType.isEmpty()) {
|
||||
wrapper.eq(Question::getQuestionType, questionType);
|
||||
}
|
||||
@@ -129,6 +134,7 @@ public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> i
|
||||
@Override
|
||||
public List<Question> getPendingQuestions() {
|
||||
return lambdaQuery()
|
||||
.and(w -> w.isNull(Question::getIsDeleted).or().eq(Question::getIsDeleted, 0))
|
||||
.eq(Question::getStatus, "pending")
|
||||
.orderByDesc(Question::getCreatedAt)
|
||||
.list();
|
||||
@@ -154,7 +160,7 @@ public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> i
|
||||
question.setScore(calculateDefaultScore(request.getQuestionType()));
|
||||
}
|
||||
question.setDocumentId(request.getDocumentId());
|
||||
question.setDocumentName(request.getDocumentName());
|
||||
// excludeStems 存储题干内容,由 AI 出题时自动填充,这里不设置
|
||||
question.setKnowledgeBasePath(request.getKnowledgeBasePath());
|
||||
question.setSourceContext(request.getSourceContext());
|
||||
if (request.getChunkIds() != null) {
|
||||
@@ -192,6 +198,35 @@ public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> i
|
||||
throw new IllegalArgumentException("题目不存在");
|
||||
}
|
||||
|
||||
if (request.getScore() != null) {
|
||||
BigDecimal maxScore = new BigDecimal("999.99");
|
||||
BigDecimal minScore = new BigDecimal("-999.99");
|
||||
if (request.getScore().compareTo(maxScore) > 0 ||
|
||||
request.getScore().compareTo(minScore) < 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"分值超出范围,允许范围: " + minScore + " ~ " + maxScore);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.getKnowledgeBasePath() != null &&
|
||||
request.getKnowledgeBasePath().length() > 500) {
|
||||
throw new IllegalArgumentException("知识库路径过长,最大允许500字符");
|
||||
}
|
||||
|
||||
Set<String> validTypes = Set.of(
|
||||
"single_choice", "multiple_choice", "true_false",
|
||||
"fill_blank", "subjective"
|
||||
);
|
||||
if (request.getQuestionType() != null &&
|
||||
!validTypes.contains(request.getQuestionType())) {
|
||||
throw new IllegalArgumentException(
|
||||
"无效的题目类型: " + request.getQuestionType());
|
||||
}
|
||||
|
||||
if (request.getContent() != null && request.getContent().isEmpty()) {
|
||||
throw new IllegalArgumentException("题目内容不能为空");
|
||||
}
|
||||
|
||||
if (request.getQuestionType() != null) {
|
||||
question.setQuestionType(request.getQuestionType());
|
||||
}
|
||||
@@ -216,9 +251,10 @@ public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> i
|
||||
if (request.getDocumentId() != null) {
|
||||
question.setDocumentId(request.getDocumentId());
|
||||
}
|
||||
if (request.getDocumentName() != null) {
|
||||
question.setDocumentName(request.getDocumentName());
|
||||
}
|
||||
// excludeStems 存储题干内容,由 AI 出题时自动填充,这里不设置
|
||||
// if (request.getDocumentName() != null) {
|
||||
// question.setExcludeStems(request.getDocumentName());
|
||||
// }
|
||||
if (request.getKnowledgeBasePath() != null) {
|
||||
question.setKnowledgeBasePath(request.getKnowledgeBasePath());
|
||||
}
|
||||
@@ -236,7 +272,12 @@ public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> i
|
||||
}
|
||||
|
||||
question.setUpdatedAt(LocalDateTime.now());
|
||||
updateById(question);
|
||||
boolean updated = updateById(question);
|
||||
if (!updated) {
|
||||
log.warn("题目更新影响行数为0,可能已被删除: id={}", id);
|
||||
throw new IllegalStateException("题目可能已被删除或归档");
|
||||
}
|
||||
|
||||
return question;
|
||||
}
|
||||
|
||||
@@ -254,6 +295,8 @@ public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> i
|
||||
public List<Question> listQuestions(String questionType, Integer difficulty, String status, String documentName, Integer limit, Long userId, Long deptId, Integer userType) {
|
||||
LambdaQueryWrapper<Question> wrapper = new LambdaQueryWrapper<>();
|
||||
|
||||
wrapper.and(w -> w.isNull(Question::getIsDeleted).or().eq(Question::getIsDeleted, 0));
|
||||
|
||||
if (questionType != null && !questionType.isEmpty()) {
|
||||
wrapper.eq(Question::getQuestionType, questionType);
|
||||
}
|
||||
@@ -263,8 +306,9 @@ public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> i
|
||||
if (status != null && !status.isEmpty()) {
|
||||
wrapper.eq(Question::getStatus, status);
|
||||
}
|
||||
// excludeStems 存储题干内容,用于AI去重和模糊搜索
|
||||
if (documentName != null && !documentName.isEmpty()) {
|
||||
wrapper.like(Question::getDocumentName, documentName);
|
||||
wrapper.like(Question::getExcludeStems, documentName);
|
||||
}
|
||||
|
||||
if (userType == 1) {
|
||||
@@ -331,6 +375,8 @@ public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> i
|
||||
private LambdaQueryWrapper<Question> buildStatsQueryWrapper(QuestionStatsRequest request) {
|
||||
LambdaQueryWrapper<Question> wrapper = new LambdaQueryWrapper<>();
|
||||
|
||||
wrapper.and(w -> w.isNull(Question::getIsDeleted).or().eq(Question::getIsDeleted, 0));
|
||||
|
||||
if (request.getStatus() != null && !request.getStatus().isEmpty()) {
|
||||
wrapper.eq(Question::getStatus, request.getStatus());
|
||||
}
|
||||
@@ -356,6 +402,7 @@ public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> i
|
||||
private Long countByType(String questionType, QuestionStatsRequest request) {
|
||||
LambdaQueryWrapper<Question> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(Question::getQuestionType, questionType);
|
||||
wrapper.and(w -> w.isNull(Question::getIsDeleted).or().eq(Question::getIsDeleted, 0));
|
||||
|
||||
if (request.getStatus() != null && !request.getStatus().isEmpty()) {
|
||||
wrapper.eq(Question::getStatus, request.getStatus());
|
||||
@@ -482,4 +529,58 @@ public class QuestionServiceImpl extends ServiceImpl<QuestionMapper, Question> i
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int softDeleteByFileId(Long fileId) {
|
||||
if (fileId == null) {
|
||||
log.warn("软删除题目失败:fileId为空");
|
||||
return 0;
|
||||
}
|
||||
File file = fileMapper.selectById(fileId);
|
||||
if (file == null) {
|
||||
log.warn("软删除题目失败:文件不存在,fileId={}", fileId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
String documentName = file.getFileName();
|
||||
if (documentName == null || documentName.isEmpty()) {
|
||||
log.warn("软删除题目失败:文件名为空,fileId={}", fileId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return softDeleteByDocumentName(documentName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int softDeleteByDocumentName(String documentName) {
|
||||
if (documentName == null || documentName.isEmpty()) {
|
||||
log.warn("软删除题目失败:文档名为空");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// excludeStems 存储题干内容,不再用于文档级删除
|
||||
// 这里保持向后兼容,使用 knowledge_base_path 进行模糊匹配
|
||||
LambdaQueryWrapper<Question> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.like(Question::getKnowledgeBasePath, documentName)
|
||||
.and(w -> w.isNull(Question::getIsDeleted).or().eq(Question::getIsDeleted, 0));
|
||||
|
||||
List<Question> questions = this.list(wrapper);
|
||||
if (questions == null || questions.isEmpty()) {
|
||||
log.info("未找到需要软删除的题目:documentName={}", documentName);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
for (Question question : questions) {
|
||||
question.setIsDeleted(1);
|
||||
question.setUpdatedAt(LocalDateTime.now());
|
||||
boolean updated = this.updateById(question);
|
||||
if (updated) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
log.info("题目软删除完成:documentName={}, 删除数量={}", documentName, count);
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,7 +498,7 @@ public class UserAnswerServiceImpl implements UserAnswerService {
|
||||
examQuestion.setDifficulty(question.getDifficulty());
|
||||
examQuestion.setScore(question.getScore());
|
||||
examQuestion.setContent(question.getContent());
|
||||
examQuestion.setDocumentName(question.getDocumentName());
|
||||
examQuestion.setExcludeStems(question.getExcludeStems());
|
||||
examQuestion.setKnowledgeBasePath(question.getKnowledgeBasePath());
|
||||
questions.add(examQuestion);
|
||||
usedQuestionIds.add(answer.getQuestionId());
|
||||
@@ -598,7 +598,8 @@ public class UserAnswerServiceImpl implements UserAnswerService {
|
||||
|
||||
File file = fileMap.get(question.getKnowledgeBasePath());
|
||||
if (file != null) {
|
||||
item.setDocumentName(file.getFileName());
|
||||
// 向后兼容,设置为文件名
|
||||
item.setExcludeStems(file.getFileName());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package top.tqx.demo_1.vo;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
@@ -57,4 +58,6 @@ public class FileVO implements Serializable {
|
||||
private String processStepStatus;
|
||||
|
||||
private String processStepMessage;
|
||||
|
||||
private JsonNode tags;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ mybatis-plus:
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
cache-enabled: true
|
||||
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:your-secret-key-for-jwt-token-generation-2024-change-in-production}
|
||||
@@ -95,8 +95,7 @@ cors:
|
||||
|
||||
logging:
|
||||
level:
|
||||
top.tqx.demo_1: DEBUG
|
||||
top.tqx.demo_1.mapper: DEBUG
|
||||
top.tqx.demo_1: INFO
|
||||
org.springframework.security: WARN
|
||||
pattern:
|
||||
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
|
||||
|
||||
@@ -58,7 +58,7 @@ mybatis-plus:
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
cache-enabled: true
|
||||
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:your-secret-key-for-jwt-token-generation-2024-change-in-production}
|
||||
@@ -95,7 +95,6 @@ cors:
|
||||
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"
|
||||
|
||||
@@ -2,4 +2,4 @@ spring:
|
||||
main:
|
||||
allow-circular-references: true
|
||||
profiles:
|
||||
active: prod
|
||||
active: dev
|
||||
Reference in New Issue
Block a user