|
|
@@ -0,0 +1,665 @@
|
|
|
+package cn.iocoder.byzs.module.web.service.ai;
|
|
|
+
|
|
|
+import cn.hutool.core.util.ObjUtil;
|
|
|
+import cn.hutool.core.util.StrUtil;
|
|
|
+import cn.iocoder.byzs.framework.common.pojo.CommonResult;
|
|
|
+import cn.iocoder.byzs.framework.common.util.object.BeanUtils;
|
|
|
+import cn.iocoder.byzs.framework.tenant.core.util.TenantUtils;
|
|
|
+import cn.iocoder.byzs.module.ai.controller.admin.chat.vo.message.AiChatMessageSendReqVO;
|
|
|
+import cn.iocoder.byzs.module.ai.controller.admin.chat.vo.message.AiChatMessageSendRespVO;
|
|
|
+import cn.iocoder.byzs.module.ai.dal.dataobject.chat.AiChatConversationDO;
|
|
|
+import cn.iocoder.byzs.module.ai.dal.dataobject.chat.AiChatMessageDO;
|
|
|
+import cn.iocoder.byzs.module.ai.dal.dataobject.model.AiApiKeyDO;
|
|
|
+import cn.iocoder.byzs.module.ai.dal.dataobject.model.AiModelDO;
|
|
|
+import cn.iocoder.byzs.module.ai.dal.mysql.chat.AiChatMessageMapper;
|
|
|
+import cn.iocoder.byzs.module.ai.enums.ErrorCodeConstants;
|
|
|
+import cn.iocoder.byzs.module.ai.service.chat.AiChatConversationService;
|
|
|
+import cn.iocoder.byzs.module.ai.service.chat.AiChatMessageService;
|
|
|
+import cn.iocoder.byzs.module.ai.service.model.AiApiKeyService;
|
|
|
+import cn.iocoder.byzs.module.ai.service.model.AiModelService;
|
|
|
+import cn.iocoder.byzs.module.web.controller.admin.ai.vo.WebQSAiChatMessageSendReqVO;
|
|
|
+import com.fasterxml.jackson.databind.JsonNode;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import com.fasterxml.jackson.databind.node.ArrayNode;
|
|
|
+import com.fasterxml.jackson.databind.node.ObjectNode;
|
|
|
+import jakarta.annotation.Resource;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.apache.poi.hwpf.extractor.WordExtractor;
|
|
|
+import org.apache.poi.ss.usermodel.Cell;
|
|
|
+import org.apache.poi.ss.usermodel.DataFormatter;
|
|
|
+import org.apache.poi.ss.usermodel.Row;
|
|
|
+import org.apache.poi.ss.usermodel.Sheet;
|
|
|
+import org.apache.poi.ss.usermodel.Workbook;
|
|
|
+import org.apache.poi.ss.usermodel.WorkbookFactory;
|
|
|
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
|
|
|
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
|
|
|
+import org.apache.poi.xwpf.usermodel.XWPFTable;
|
|
|
+import org.apache.poi.xwpf.usermodel.XWPFTableCell;
|
|
|
+import org.apache.poi.xwpf.usermodel.XWPFTableRow;
|
|
|
+import org.springframework.ai.chat.messages.MessageType;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import org.springframework.validation.annotation.Validated;
|
|
|
+import org.springframework.web.multipart.MultipartFile;
|
|
|
+import reactor.core.publisher.Flux;
|
|
|
+import reactor.core.scheduler.Schedulers;
|
|
|
+
|
|
|
+import java.io.IOException;
|
|
|
+import java.io.InputStream;
|
|
|
+import java.net.HttpURLConnection;
|
|
|
+import java.net.InetAddress;
|
|
|
+import java.net.URI;
|
|
|
+import java.net.URL;
|
|
|
+import java.net.URLConnection;
|
|
|
+import java.net.http.HttpClient;
|
|
|
+import java.net.http.HttpRequest;
|
|
|
+import java.net.http.HttpResponse;
|
|
|
+import java.time.Duration;
|
|
|
+import java.time.LocalDateTime;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Base64;
|
|
|
+import java.util.List;
|
|
|
+import java.util.concurrent.atomic.AtomicBoolean;
|
|
|
+
|
|
|
+import static cn.iocoder.byzs.framework.common.exception.util.ServiceExceptionUtil.exception;
|
|
|
+import static cn.iocoder.byzs.framework.common.pojo.CommonResult.error;
|
|
|
+import static cn.iocoder.byzs.framework.common.pojo.CommonResult.success;
|
|
|
+import static cn.iocoder.byzs.module.ai.enums.ErrorCodeConstants.CHAT_CONVERSATION_NOT_EXISTS;
|
|
|
+
|
|
|
+/**
|
|
|
+ * Web 端支持附件的 DeepSeek AI 问答服务。
|
|
|
+ */
|
|
|
+@Service
|
|
|
+@Validated
|
|
|
+@Slf4j
|
|
|
+public class WebQSAiServiceImpl {
|
|
|
+
|
|
|
+ // DeepSeek API URL 固定
|
|
|
+ private static final String DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions";
|
|
|
+
|
|
|
+ /** 单个 URL 附件允许下载的最大文件大小:10 MB。 */
|
|
|
+ private static final long MAX_DOCUMENT_SIZE_BYTES = 10L * 1024 * 1024;
|
|
|
+ /** 一次请求中,本地上传附件允许的总文件大小:15 MB。 */
|
|
|
+ private static final long MAX_TOTAL_DOCUMENT_SIZE_BYTES = 15L * 1024 * 1024;
|
|
|
+ /** 一次请求允许携带的附件总数,包括 URL 附件和本地上传附件。 */
|
|
|
+ private static final int MAX_ATTACHMENTS = 3;
|
|
|
+ /** 每个附件解析后最多保留的文本字符数,超出部分会被截断后再发送给模型。 */
|
|
|
+ private static final int MAX_EXTRACTED_TEXT_CHARS = 20_000;
|
|
|
+ /** 带附件问答时,用户手动输入的问题最大字符数;不包含附件解析出的文本。 */
|
|
|
+ private static final int MAX_USER_CONTENT_CHARS = 2_000;
|
|
|
+ /** 携带历史上下文时,最多选取的最近历史消息数量。 */
|
|
|
+ private static final int MAX_CONTEXT_MESSAGES = 6;
|
|
|
+ /** 筛选历史上下文时允许累计的最大字符数,用于控制发送给模型的上下文长度。 */
|
|
|
+ private static final int MAX_CONTEXT_CHARS = 12_000;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private AiChatConversationService chatConversationService;
|
|
|
+ @Resource
|
|
|
+ private AiChatMessageService chatMessageService;
|
|
|
+ @Resource
|
|
|
+ private AiChatMessageMapper chatMessageMapper;
|
|
|
+ @Resource
|
|
|
+ private AiModelService modelService;
|
|
|
+ @Resource
|
|
|
+ private AiApiKeyService apiKeyService;
|
|
|
+
|
|
|
+ private final HttpClient httpClient = HttpClient.newBuilder()
|
|
|
+ .connectTimeout(Duration.ofSeconds(30))
|
|
|
+ .build();
|
|
|
+ private final ObjectMapper objectMapper = new ObjectMapper();
|
|
|
+
|
|
|
+ public Flux<CommonResult<AiChatMessageSendRespVO>> sendChatMessageStream(
|
|
|
+ WebQSAiChatMessageSendReqVO sendReqVO, Long userId) {
|
|
|
+ return sendChatMessageStream(sendReqVO, userId, List.of());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 处理 multipart/form-data 提交的本地文件。
|
|
|
+ */
|
|
|
+ public Flux<CommonResult<AiChatMessageSendRespVO>> sendChatMessageStream(
|
|
|
+ WebQSAiChatMessageSendReqVO sendReqVO, Long userId, List<MultipartFile> multipartFiles) {
|
|
|
+ List<MultipartFile> files = multipartFiles == null ? List.of() : multipartFiles;
|
|
|
+ if (sendReqVO.getAttachments() == null || sendReqVO.getAttachments().isEmpty()) {
|
|
|
+ if (files.isEmpty()) {
|
|
|
+ return sendTextChatMessageStream(sendReqVO, userId);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ int urlAttachmentCount = sendReqVO.getAttachments() == null ? 0 : sendReqVO.getAttachments().size();
|
|
|
+ if (urlAttachmentCount + files.size() > MAX_ATTACHMENTS) {
|
|
|
+ throw new IllegalArgumentException("单次最多上传 " + MAX_ATTACHMENTS + " 个附件");
|
|
|
+ }
|
|
|
+ AiChatConversationDO conversation = chatConversationService
|
|
|
+ .validateChatConversationExists(sendReqVO.getConversationId());
|
|
|
+ if (ObjUtil.notEqual(conversation.getUserId(), userId)) {
|
|
|
+ throw exception(CHAT_CONVERSATION_NOT_EXISTS);
|
|
|
+ }
|
|
|
+
|
|
|
+ AiModelDO model = modelService.validateModel(conversation.getModelId());
|
|
|
+ AiApiKeyDO apiKey = apiKeyService.validateApiKey(model.getKeyId());
|
|
|
+
|
|
|
+ String userContent = StrUtil.blankToDefault(sendReqVO.getContent(), "请分析附件内容。");
|
|
|
+ if (userContent.length() > MAX_USER_CONTENT_CHARS) {
|
|
|
+ throw new IllegalArgumentException("提问内容不能超过 " + MAX_USER_CONTENT_CHARS + " 个字符");
|
|
|
+ }
|
|
|
+ validateMultipartFilesSize(files);
|
|
|
+ List<AiChatMessageDO> historyMessages = chatMessageMapper.selectListByConversationId(conversation.getId());
|
|
|
+
|
|
|
+ AiChatMessageDO userMessage = createChatMessage(conversation, model, userId, null,
|
|
|
+ MessageType.USER, userContent, sendReqVO.getUseContext());
|
|
|
+ AiChatMessageDO assistantMessage = createChatMessage(conversation, model, userId, userMessage.getId(),
|
|
|
+ MessageType.ASSISTANT, "", sendReqVO.getUseContext());
|
|
|
+
|
|
|
+ StringBuilder answer = new StringBuilder();
|
|
|
+ AtomicBoolean serviceClosed = new AtomicBoolean(false);
|
|
|
+
|
|
|
+ return Flux.<CommonResult<AiChatMessageSendRespVO>>create(sink -> {
|
|
|
+ try {
|
|
|
+ // 构建 DeepSeek 请求
|
|
|
+ String requestBody = buildDeepSeekRequest(conversation, model, apiKey, historyMessages,
|
|
|
+ sendReqVO, userContent, files);
|
|
|
+
|
|
|
+ // 构建 HTTP 请求(URL 固定,API Key 从数据库获取)
|
|
|
+ HttpRequest httpRequest = HttpRequest.newBuilder()
|
|
|
+ .uri(URI.create(DEEPSEEK_API_URL))
|
|
|
+ .header("Content-Type", "application/json")
|
|
|
+ .header("Authorization", "Bearer " + apiKey.getApiKey())
|
|
|
+ .timeout(Duration.ofSeconds(300))
|
|
|
+ .POST(HttpRequest.BodyPublishers.ofString(requestBody))
|
|
|
+ .build();
|
|
|
+
|
|
|
+ // 发送流式请求
|
|
|
+ httpClient.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofLines())
|
|
|
+ .thenAccept(response -> {
|
|
|
+ if (response.statusCode() != 200) {
|
|
|
+ String errorBody = response.body() != null ? response.body().toString() : "";
|
|
|
+ log.error("DeepSeek API 错误: HTTP {}, body: {}", response.statusCode(), errorBody);
|
|
|
+ sink.error(new RuntimeException("DeepSeek API 错误: HTTP " + response.statusCode()));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ response.body().forEach(line -> {
|
|
|
+ if (!line.startsWith("data: ")) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ String data = line.substring(6).trim();
|
|
|
+
|
|
|
+ if ("[DONE]".equals(data)) {
|
|
|
+ TenantUtils.executeIgnore(() ->
|
|
|
+ chatMessageMapper.updateById(
|
|
|
+ new AiChatMessageDO().setId(assistantMessage.getId())
|
|
|
+ .setContent(answer.toString())));
|
|
|
+ sink.complete();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ JsonNode chunk = objectMapper.readTree(data);
|
|
|
+ JsonNode choices = chunk.path("choices");
|
|
|
+ if (choices.isEmpty()) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ JsonNode delta = choices.path(0).path("delta");
|
|
|
+ // 跳过空对象
|
|
|
+ if (delta == null || delta.isNull() || delta.size() == 0) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理普通文本内容
|
|
|
+ if (delta.has("content")) {
|
|
|
+ JsonNode contentNode = delta.path("content");
|
|
|
+ if (!contentNode.isNull()) {
|
|
|
+ String deltaText = contentNode.asText();
|
|
|
+ if (StrUtil.isNotEmpty(deltaText) && !"null".equals(deltaText)) {
|
|
|
+ answer.append(deltaText);
|
|
|
+ sink.next(success(createTextResponse(userMessage, assistantMessage, deltaText)));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 记录思考过程(仅日志,不输出)
|
|
|
+ if (delta.has("reasoning_content")) {
|
|
|
+ JsonNode reasoningNode = delta.path("reasoning_content");
|
|
|
+ if (!reasoningNode.isNull()) {
|
|
|
+ String reasoning = reasoningNode.asText();
|
|
|
+ if (StrUtil.isNotEmpty(reasoning) && !"null".equals(reasoning)) {
|
|
|
+ log.debug("DeepSeek 思考过程: {}", reasoning);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("解析 DeepSeek 响应失败: {}, line: {}", e.getMessage(), line);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ })
|
|
|
+ .exceptionally(e -> {
|
|
|
+ log.error("DeepSeek API 调用异常", e);
|
|
|
+ sink.error(e);
|
|
|
+ return null;
|
|
|
+ });
|
|
|
+
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("构建 DeepSeek 请求失败", e);
|
|
|
+ sink.error(e);
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .subscribeOn(Schedulers.boundedElastic())
|
|
|
+ .doFinally(signalType -> {
|
|
|
+ if (serviceClosed.compareAndSet(false, true)) {
|
|
|
+ if (answer.length() > 0) {
|
|
|
+ TenantUtils.executeIgnore(() ->
|
|
|
+ chatMessageMapper.updateById(
|
|
|
+ new AiChatMessageDO().setId(assistantMessage.getId())
|
|
|
+ .setContent(answer.toString())));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .onErrorResume(throwable -> {
|
|
|
+ log.error("[sendChatMessageStream][userId({}) 会话({}) 调用 DeepSeek 附件问答失败]",
|
|
|
+ userId, conversation.getId(), throwable);
|
|
|
+ return Flux.just(error(ErrorCodeConstants.CHAT_STREAM_ERROR));
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 纯文本请求复用通用聊天链路
|
|
|
+ */
|
|
|
+ private Flux<CommonResult<AiChatMessageSendRespVO>> sendTextChatMessageStream(
|
|
|
+ WebQSAiChatMessageSendReqVO sendReqVO, Long userId) {
|
|
|
+ if (StrUtil.isBlank(sendReqVO.getContent())) {
|
|
|
+ throw new IllegalArgumentException("文本问答内容不能为空");
|
|
|
+ }
|
|
|
+ AiChatMessageSendReqVO textSendReqVO = new AiChatMessageSendReqVO();
|
|
|
+ textSendReqVO.setConversationId(sendReqVO.getConversationId());
|
|
|
+ textSendReqVO.setContent(sendReqVO.getContent());
|
|
|
+ textSendReqVO.setUseContext(sendReqVO.getUseContext());
|
|
|
+ textSendReqVO.setPlayAudio(sendReqVO.getPlayAudio());
|
|
|
+ // WebQSAi 专用问答显式开启知识库引用;通用聊天接口仍使用原有两参数方法,行为不变。
|
|
|
+ return chatMessageService.sendChatMessageStream(textSendReqVO, userId, true);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 构建 DeepSeek API 请求体
|
|
|
+ */
|
|
|
+ private String buildDeepSeekRequest(AiChatConversationDO conversation, AiModelDO model, AiApiKeyDO apiKey,
|
|
|
+ List<AiChatMessageDO> historyMessages,
|
|
|
+ WebQSAiChatMessageSendReqVO sendReqVO,
|
|
|
+ String userContent,
|
|
|
+ List<MultipartFile> multipartFiles) throws Exception {
|
|
|
+
|
|
|
+ ObjectNode root = objectMapper.createObjectNode();
|
|
|
+ root.put("model", model.getModel());
|
|
|
+ root.put("temperature", model.getTemperature() != null ? model.getTemperature() : 0.1);
|
|
|
+ root.put("max_tokens", model.getMaxTokens() != null ? model.getMaxTokens() : 4000);
|
|
|
+ root.put("top_p", 0.9);
|
|
|
+ root.put("stream", true);
|
|
|
+
|
|
|
+ ArrayNode messages = objectMapper.createArrayNode();
|
|
|
+
|
|
|
+ // 1. 添加 System Prompt(评分规则)
|
|
|
+ ObjectNode systemMsg = objectMapper.createObjectNode();
|
|
|
+ systemMsg.put("role", "system");
|
|
|
+ systemMsg.put("content", conversation.getDescription());
|
|
|
+ messages.add(systemMsg);
|
|
|
+
|
|
|
+ // 2. 添加历史上下文
|
|
|
+ if (Boolean.TRUE.equals(sendReqVO.getUseContext())) {
|
|
|
+ int configuredMaxContexts = model.getMaxContexts() == null ? historyMessages.size() : model.getMaxContexts();
|
|
|
+ int maxContexts = Math.min(configuredMaxContexts, MAX_CONTEXT_MESSAGES);
|
|
|
+ int startIndex = Math.max(0, historyMessages.size() - maxContexts);
|
|
|
+ List<AiChatMessageDO> contextList = new ArrayList<>();
|
|
|
+ int contextChars = 0;
|
|
|
+
|
|
|
+ for (int i = historyMessages.size() - 1; i >= startIndex && contextChars < MAX_CONTEXT_CHARS; i--) {
|
|
|
+ AiChatMessageDO history = historyMessages.get(i);
|
|
|
+ if (MessageType.USER.getValue().equals(history.getType())
|
|
|
+ || MessageType.ASSISTANT.getValue().equals(history.getType())) {
|
|
|
+ String content = StrUtil.nullToEmpty(history.getContent());
|
|
|
+ int remainingChars = MAX_CONTEXT_CHARS - contextChars;
|
|
|
+ if (content.length() > remainingChars) {
|
|
|
+ content = content.substring(content.length() - remainingChars);
|
|
|
+ }
|
|
|
+ contextList.add(0, history);
|
|
|
+ contextChars += content.length();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ for (AiChatMessageDO history : contextList) {
|
|
|
+ ObjectNode msg = objectMapper.createObjectNode();
|
|
|
+ msg.put("role", history.getType());
|
|
|
+ msg.put("content", history.getContent());
|
|
|
+ messages.add(msg);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. 构建用户消息内容(包含附件文本)
|
|
|
+ String fullUserContent = buildFullUserContent(sendReqVO, userContent, multipartFiles);
|
|
|
+
|
|
|
+ ObjectNode userMsg = objectMapper.createObjectNode();
|
|
|
+ userMsg.put("role", "user");
|
|
|
+ userMsg.put("content", fullUserContent);
|
|
|
+ messages.add(userMsg);
|
|
|
+
|
|
|
+ root.set("messages", messages);
|
|
|
+ return objectMapper.writeValueAsString(root);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 构建完整的用户消息内容(包含附件解析的文本)
|
|
|
+ */
|
|
|
+ private String buildFullUserContent(WebQSAiChatMessageSendReqVO sendReqVO,
|
|
|
+ String userContent,
|
|
|
+ List<MultipartFile> multipartFiles) {
|
|
|
+ StringBuilder extractedText = new StringBuilder();
|
|
|
+
|
|
|
+ // 处理 URL 附件
|
|
|
+ if (sendReqVO.getAttachments() != null) {
|
|
|
+ for (WebQSAiChatMessageSendReqVO.Attachment attachment : sendReqVO.getAttachments()) {
|
|
|
+ String url = attachment.getUrl();
|
|
|
+ String name = attachment.getName();
|
|
|
+ if (StrUtil.isNotBlank(url)) {
|
|
|
+ try {
|
|
|
+ String text = downloadAndExtractText(url, name);
|
|
|
+ if (StrUtil.isNotBlank(text)) {
|
|
|
+ extractedText.append("附件《").append(StrUtil.blankToDefault(name, "未知文件"))
|
|
|
+ .append("》内容:\n").append(text).append("\n\n");
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("下载或解析附件失败: {}, error: {}", name, e.getMessage());
|
|
|
+ extractedText.append("附件《").append(StrUtil.blankToDefault(name, "未知文件"))
|
|
|
+ .append("》解析失败,请检查文件格式。\n\n");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理上传的 MultipartFile
|
|
|
+ if (multipartFiles != null) {
|
|
|
+ for (MultipartFile file : multipartFiles) {
|
|
|
+ if (file == null || file.isEmpty()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ String fileName = file.getOriginalFilename();
|
|
|
+ byte[] content = file.getBytes();
|
|
|
+ String text = extractOfficeText(content, fileName);
|
|
|
+ if (StrUtil.isNotBlank(text)) {
|
|
|
+ extractedText.append("附件《").append(StrUtil.blankToDefault(fileName, "未知文件"))
|
|
|
+ .append("》内容:\n").append(text).append("\n\n");
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("解析上传文件失败: {}, error: {}", file.getOriginalFilename(), e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 组合最终内容
|
|
|
+ StringBuilder fullContent = new StringBuilder();
|
|
|
+ if (extractedText.length() > 0) {
|
|
|
+ fullContent.append("【附件内容】\n").append(extractedText);
|
|
|
+ }
|
|
|
+ if (StrUtil.isNotBlank(userContent)) {
|
|
|
+ if (fullContent.length() > 0) {
|
|
|
+ fullContent.append("\n【用户问题】\n");
|
|
|
+ }
|
|
|
+ fullContent.append(userContent);
|
|
|
+ }
|
|
|
+ return fullContent.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 下载并提取附件文本
|
|
|
+ */
|
|
|
+ private String downloadAndExtractText(String fileUrl, String fileName) {
|
|
|
+ try {
|
|
|
+ URI uri = URI.create(fileUrl);
|
|
|
+ validatePublicHost(uri);
|
|
|
+ URLConnection connection = new URL(fileUrl).openConnection();
|
|
|
+ connection.setConnectTimeout(10_000);
|
|
|
+ connection.setReadTimeout(30_000);
|
|
|
+ if (connection instanceof HttpURLConnection httpConnection) {
|
|
|
+ httpConnection.setInstanceFollowRedirects(false);
|
|
|
+ if (httpConnection.getResponseCode() != HttpURLConnection.HTTP_OK) {
|
|
|
+ throw new IOException("附件下载失败,HTTP 状态码: " + httpConnection.getResponseCode());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (connection.getContentLengthLong() > MAX_DOCUMENT_SIZE_BYTES) {
|
|
|
+ throw new IllegalArgumentException("单个附件大小不能超过 10 MB");
|
|
|
+ }
|
|
|
+ try (InputStream inputStream = connection.getInputStream()) {
|
|
|
+ byte[] content = readWithSizeLimit(inputStream);
|
|
|
+ return extractOfficeText(content, fileName);
|
|
|
+ }
|
|
|
+ } catch (IOException e) {
|
|
|
+ throw new IllegalArgumentException("下载 Office 附件失败", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private byte[] readWithSizeLimit(InputStream inputStream) throws IOException {
|
|
|
+ try (java.io.ByteArrayOutputStream outputStream = new java.io.ByteArrayOutputStream()) {
|
|
|
+ byte[] buffer = new byte[8192];
|
|
|
+ long total = 0;
|
|
|
+ int read;
|
|
|
+ while ((read = inputStream.read(buffer)) != -1) {
|
|
|
+ total += read;
|
|
|
+ if (total > MAX_DOCUMENT_SIZE_BYTES) {
|
|
|
+ throw new IllegalArgumentException("单个附件大小不能超过 10 MB");
|
|
|
+ }
|
|
|
+ outputStream.write(buffer, 0, read);
|
|
|
+ }
|
|
|
+ return outputStream.toByteArray();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void validatePublicHost(URI uri) {
|
|
|
+ String host = uri.getHost();
|
|
|
+ if (StrUtil.isBlank(host)) {
|
|
|
+ throw new IllegalArgumentException("附件 URL 缺少主机名");
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ for (InetAddress address : InetAddress.getAllByName(host)) {
|
|
|
+ if (address.isAnyLocalAddress() || address.isLoopbackAddress() || address.isSiteLocalAddress()
|
|
|
+ || address.isLinkLocalAddress() || address.isMulticastAddress()) {
|
|
|
+ throw new IllegalArgumentException("附件 URL 不允许使用内网地址");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (IOException e) {
|
|
|
+ throw new IllegalArgumentException("附件 URL 域名无法解析", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 提取 Office 文档文本
|
|
|
+ */
|
|
|
+ private String extractOfficeText(byte[] content, String fileName) {
|
|
|
+ if (content == null || content.length == 0) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ String extension = StrUtil.subAfter(fileName, '.', true).toLowerCase();
|
|
|
+ try {
|
|
|
+ String text = switch (extension) {
|
|
|
+ case "docx" -> extractDocxText(content);
|
|
|
+ case "doc" -> extractDocText(content);
|
|
|
+ case "xlsx", "xls" -> extractExcelText(content);
|
|
|
+ default -> {
|
|
|
+ if (extension.equals("txt") || extension.equals("csv")) {
|
|
|
+ yield new String(content, java.nio.charset.StandardCharsets.UTF_8);
|
|
|
+ }
|
|
|
+ yield "";
|
|
|
+ }
|
|
|
+ };
|
|
|
+ if (StrUtil.isBlank(text)) {
|
|
|
+ throw new IllegalArgumentException("未从附件中提取到可供 AI 分析的文本:" + fileName);
|
|
|
+ }
|
|
|
+ return limitExtractedText(text);
|
|
|
+ } catch (IOException e) {
|
|
|
+ throw new IllegalArgumentException("解析 Office 附件失败:" + fileName, e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String extractDocxText(byte[] content) throws IOException {
|
|
|
+ StringBuilder text = new StringBuilder();
|
|
|
+ try (XWPFDocument document = new XWPFDocument(new java.io.ByteArrayInputStream(content))) {
|
|
|
+ for (XWPFParagraph paragraph : document.getParagraphs()) {
|
|
|
+ appendLine(text, paragraph.getText());
|
|
|
+ }
|
|
|
+ for (XWPFTable table : document.getTables()) {
|
|
|
+ for (XWPFTableRow row : table.getRows()) {
|
|
|
+ StringBuilder rowText = new StringBuilder();
|
|
|
+ for (XWPFTableCell cell : row.getTableCells()) {
|
|
|
+ if (!rowText.isEmpty()) {
|
|
|
+ rowText.append(" | ");
|
|
|
+ }
|
|
|
+ rowText.append(cell.getText().replace('\n', ' ').trim());
|
|
|
+ }
|
|
|
+ appendLine(text, rowText.toString());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return text.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ private String extractDocText(byte[] content) throws IOException {
|
|
|
+ try (WordExtractor extractor = new WordExtractor(new java.io.ByteArrayInputStream(content))) {
|
|
|
+ return extractor.getText();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String extractExcelText(byte[] content) throws IOException {
|
|
|
+ StringBuilder text = new StringBuilder();
|
|
|
+ try (Workbook workbook = WorkbookFactory.create(new java.io.ByteArrayInputStream(content))) {
|
|
|
+ DataFormatter formatter = new DataFormatter();
|
|
|
+ for (Sheet sheet : workbook) {
|
|
|
+ appendLine(text, "工作表:" + sheet.getSheetName());
|
|
|
+ for (Row row : sheet) {
|
|
|
+ StringBuilder rowText = new StringBuilder();
|
|
|
+ for (Cell cell : row) {
|
|
|
+ if (!rowText.isEmpty()) {
|
|
|
+ rowText.append(" | ");
|
|
|
+ }
|
|
|
+ rowText.append(formatter.formatCellValue(cell));
|
|
|
+ }
|
|
|
+ if (!rowText.isEmpty()) {
|
|
|
+ appendLine(text, rowText.toString());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return text.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ private void appendLine(StringBuilder text, String line) {
|
|
|
+ if (StrUtil.isNotBlank(line)) {
|
|
|
+ text.append(line.trim()).append('\n');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String limitExtractedText(String text) {
|
|
|
+ if (text.length() <= MAX_EXTRACTED_TEXT_CHARS) {
|
|
|
+ return text;
|
|
|
+ }
|
|
|
+ return text.substring(0, MAX_EXTRACTED_TEXT_CHARS)
|
|
|
+ + "\n[附件文本过长,已截取前 " + MAX_EXTRACTED_TEXT_CHARS + " 个字符]";
|
|
|
+ }
|
|
|
+
|
|
|
+ private void validateMultipartFilesSize(List<MultipartFile> files) {
|
|
|
+ long totalSize = files.stream()
|
|
|
+ .filter(file -> file != null)
|
|
|
+ .mapToLong(MultipartFile::getSize)
|
|
|
+ .sum();
|
|
|
+ if (totalSize > MAX_TOTAL_DOCUMENT_SIZE_BYTES) {
|
|
|
+ throw new IllegalArgumentException("附件总大小不能超过 15 MB");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean isImage(String extension, String contentType) {
|
|
|
+ return StrUtil.startWithIgnoreCase(contentType, "image/")
|
|
|
+ || "jpg".equals(extension) || "jpeg".equals(extension) || "png".equals(extension)
|
|
|
+ || "webp".equals(extension) || "gif".equals(extension) || "bmp".equals(extension);
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean isDocument(String extension, String contentType) {
|
|
|
+ return "pdf".equals(extension) || "doc".equals(extension) || "docx".equals(extension)
|
|
|
+ || "xls".equals(extension) || "xlsx".equals(extension)
|
|
|
+ || "application/pdf".equalsIgnoreCase(contentType)
|
|
|
+ || "application/msword".equalsIgnoreCase(contentType)
|
|
|
+ || "application/vnd.openxmlformats-officedocument.wordprocessingml.document".equalsIgnoreCase(contentType)
|
|
|
+ || "application/vnd.ms-excel".equalsIgnoreCase(contentType)
|
|
|
+ || "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".equalsIgnoreCase(contentType);
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean isOfficeDocument(String extension, String contentType) {
|
|
|
+ return "doc".equals(extension) || "docx".equals(extension) || "xls".equals(extension) || "xlsx".equals(extension)
|
|
|
+ || "application/msword".equalsIgnoreCase(contentType)
|
|
|
+ || "application/vnd.openxmlformats-officedocument.wordprocessingml.document".equalsIgnoreCase(contentType)
|
|
|
+ || "application/vnd.ms-excel".equalsIgnoreCase(contentType)
|
|
|
+ || "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".equalsIgnoreCase(contentType);
|
|
|
+ }
|
|
|
+
|
|
|
+ private String normalizeOfficeFileName(String fileName, String contentType) {
|
|
|
+ if (StrUtil.isNotBlank(StrUtil.subAfter(fileName, '.', true))) {
|
|
|
+ return fileName;
|
|
|
+ }
|
|
|
+ if ("application/msword".equalsIgnoreCase(contentType)) {
|
|
|
+ return fileName + ".doc";
|
|
|
+ }
|
|
|
+ if ("application/vnd.openxmlformats-officedocument.wordprocessingml.document".equalsIgnoreCase(contentType)) {
|
|
|
+ return fileName + ".docx";
|
|
|
+ }
|
|
|
+ if ("application/vnd.ms-excel".equalsIgnoreCase(contentType)) {
|
|
|
+ return fileName + ".xls";
|
|
|
+ }
|
|
|
+ if ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".equalsIgnoreCase(contentType)) {
|
|
|
+ return fileName + ".xlsx";
|
|
|
+ }
|
|
|
+ return fileName;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String getFileName(String url) {
|
|
|
+ try {
|
|
|
+ URI uri = URI.create(url);
|
|
|
+ String path = uri.getPath();
|
|
|
+ String fileName = path == null ? null : StrUtil.subAfter(path, '/', true);
|
|
|
+ return StrUtil.blankToDefault(fileName, "attachment");
|
|
|
+ } catch (IllegalArgumentException e) {
|
|
|
+ throw new IllegalArgumentException("附件 URL 不合法", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String normalizeAttachmentUrl(String url) {
|
|
|
+ try {
|
|
|
+ URI uri = URI.create(url);
|
|
|
+ if (!"http".equalsIgnoreCase(uri.getScheme()) && !"https".equalsIgnoreCase(uri.getScheme())) {
|
|
|
+ throw new IllegalArgumentException("附件 URL 必须使用 HTTP 或 HTTPS 协议");
|
|
|
+ }
|
|
|
+ return uri.toASCIIString();
|
|
|
+ } catch (IllegalArgumentException e) {
|
|
|
+ throw new IllegalArgumentException("附件 URL 不合法", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private AiChatMessageDO createChatMessage(AiChatConversationDO conversation, AiModelDO model, Long userId,
|
|
|
+ Long replyId, MessageType messageType, String content, Boolean useContext) {
|
|
|
+ AiChatMessageDO message = new AiChatMessageDO()
|
|
|
+ .setConversationId(conversation.getId())
|
|
|
+ .setReplyId(replyId)
|
|
|
+ .setModel(model.getModel())
|
|
|
+ .setModelId(model.getId())
|
|
|
+ .setUserId(userId)
|
|
|
+ .setRoleId(conversation.getRoleId())
|
|
|
+ .setType(messageType.getValue())
|
|
|
+ .setContent(content)
|
|
|
+ .setUseContext(useContext);
|
|
|
+ message.setCreateTime(LocalDateTime.now());
|
|
|
+ chatMessageMapper.insert(message);
|
|
|
+ return message;
|
|
|
+ }
|
|
|
+
|
|
|
+ private AiChatMessageSendRespVO createTextResponse(AiChatMessageDO userMessage,
|
|
|
+ AiChatMessageDO assistantMessage, String delta) {
|
|
|
+ return new AiChatMessageSendRespVO()
|
|
|
+ .setEventType("TEXT")
|
|
|
+ .setSend(BeanUtils.toBean(userMessage, AiChatMessageSendRespVO.Message.class))
|
|
|
+ .setReceive(BeanUtils.toBean(assistantMessage, AiChatMessageSendRespVO.Message.class).setContent(delta));
|
|
|
+ }
|
|
|
+}
|