|
|
@@ -0,0 +1,439 @@
|
|
|
+package com.zsjz.ai.module.agent.attachment;
|
|
|
+
|
|
|
+import cn.hutool.core.io.FileUtil;
|
|
|
+import cn.hutool.core.util.IdUtil;
|
|
|
+import cn.hutool.core.util.StrUtil;
|
|
|
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
|
|
+import com.zsjz.ai.common.exception.ServerException;
|
|
|
+import com.zsjz.ai.common.utils.Json;
|
|
|
+import lombok.RequiredArgsConstructor;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.apache.tika.Tika;
|
|
|
+import org.apache.tika.metadata.Metadata;
|
|
|
+import org.apache.tika.parser.AutoDetectParser;
|
|
|
+import org.apache.tika.sax.BodyContentHandler;
|
|
|
+import org.springframework.scheduling.annotation.Scheduled;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import org.springframework.web.multipart.MultipartFile;
|
|
|
+
|
|
|
+import java.io.File;
|
|
|
+import java.io.Writer;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.nio.file.Files;
|
|
|
+import java.nio.file.Path;
|
|
|
+import java.nio.file.Paths;
|
|
|
+import java.time.LocalDateTime;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 对话附件编排:上传(校验→落盘→路由提取→登记)与发送时的加载(三重归属校验→注入块)。
|
|
|
+ *
|
|
|
+ * <h3>三路(f.txt 步骤 4-5 的最小流程线)</h3>
|
|
|
+ * <ul>
|
|
|
+ * <li><b>A 内联</b>:小文档/小表格全文进消息(Claude document block 同款);</li>
|
|
|
+ * <li><b>B Python 直读</b>:大表格只流式扫描行数/表头/样本,注入文件路径 + 指引,
|
|
|
+ * Agent 用 {@code execute_python} 的 pandas 直读原文件<b>全量计算</b>(不截断不算错);</li>
|
|
|
+ * <li><b>C RAG</b>:大文档全文落 sidecar(写入上限 = 索引上限,50 万字),
|
|
|
+ * 向量化后按<b>用户问题</b>检索 top-k 片段注入。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <h3>隔离</h3>
|
|
|
+ * 磁盘 {@code {root}/chat-attachments/{userId}/{caseId}/{sessionId}/...} + 表级三重归属 +
|
|
|
+ * 向量表按案件分表、检索按会话过滤。{@link #loadForSession} 任何一重不匹配直接 400。
|
|
|
+ *
|
|
|
+ * @author cc
|
|
|
+ * @since 2026/9/29
|
|
|
+ */
|
|
|
+@Slf4j
|
|
|
+@Service
|
|
|
+@RequiredArgsConstructor
|
|
|
+public class ChatAttachmentService {
|
|
|
+
|
|
|
+ private final ChatAttachmentMapper attachmentMapper;
|
|
|
+
|
|
|
+ private final AttachmentDocIndexer docIndexer;
|
|
|
+
|
|
|
+ private final ChatAttachmentProperties props;
|
|
|
+
|
|
|
+ private final Tika tika = new Tika();
|
|
|
+
|
|
|
+ /** 上传响应 */
|
|
|
+ public record AttachmentVO(Long id, String name, long size, String kind,
|
|
|
+ int rows, int chars, boolean truncated, int indexedChunks, String status) {
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 上传 ====================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 上传并按路由提取。UNSUPPORTED 直接 400(不落盘),FAILED 落盘登记但状态失败可重传。
|
|
|
+ */
|
|
|
+ public AttachmentVO upload(MultipartFile file, Long userId, Long caseId, Long sessionId) {
|
|
|
+ if (!props.isEnabled()) {
|
|
|
+ throw new ServerException(400, "附件功能未启用");
|
|
|
+ }
|
|
|
+ if (file == null || file.isEmpty()) {
|
|
|
+ throw new ServerException(400, "附件为空");
|
|
|
+ }
|
|
|
+ String safeName = sanitizeFileName(file.getOriginalFilename());
|
|
|
+ String suffix = AttachmentContentRouter.suffixOf(safeName);
|
|
|
+ if (!props.effectiveSuffixes().contains(suffix)) {
|
|
|
+ throw new ServerException(400, "不支持的文件类型: " + suffix + "(暂支持文档与表格,图片/音视频/压缩包不支持)");
|
|
|
+ }
|
|
|
+ long size = file.getSize();
|
|
|
+ String mime = detectMime(file, safeName);
|
|
|
+ AttachmentContentRouter.Kind kind = AttachmentContentRouter.route(safeName, mime);
|
|
|
+ if (kind == AttachmentContentRouter.Kind.UNSUPPORTED) {
|
|
|
+ throw new ServerException(400, "该文件类型暂不支持解析: " + safeName + "(无 OCR/ASR 能力)");
|
|
|
+ }
|
|
|
+ long limitMb = kind == AttachmentContentRouter.Kind.TABLE ? props.getMaxSheetMb() : props.getMaxDocMb();
|
|
|
+ if (size > limitMb * 1024 * 1024) {
|
|
|
+ throw new ServerException(400, "文件超过大小上限(" + limitMb + "MB): " + safeName);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 落盘(三层隔离路径):{root}/chat-attachments/{userId}/{caseId}/{sessionId}/{雪花}_{safeName}
|
|
|
+ Path dir = baseDir(userId, caseId, sessionId);
|
|
|
+ Path target = dir.resolve(IdUtil.getSnowflakeNextId() + "_" + safeName).normalize();
|
|
|
+ if (!target.startsWith(dir)) {
|
|
|
+ throw new ServerException(400, "非法文件名: " + safeName);
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ Files.createDirectories(dir);
|
|
|
+ file.transferTo(target);
|
|
|
+ } catch (Exception e) {
|
|
|
+ throw new ServerException(500, "附件保存失败: " + e.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ ChatAttachment att = new ChatAttachment();
|
|
|
+ att.setId(IdUtil.getSnowflakeNextId());
|
|
|
+ att.setUserId(userId);
|
|
|
+ att.setCaseId(caseId);
|
|
|
+ att.setSessionId(sessionId);
|
|
|
+ att.setFileName(safeName);
|
|
|
+ att.setFilePath(target.toString());
|
|
|
+ att.setMime(mime);
|
|
|
+ att.setKind(kind.name());
|
|
|
+ att.setSizeBytes(size);
|
|
|
+ att.setStatus("FAILED");
|
|
|
+ att.setIndexStatus("NONE");
|
|
|
+ att.setTruncated(0);
|
|
|
+ att.setCreateTime(LocalDateTime.now());
|
|
|
+ att.setUpdateTime(LocalDateTime.now());
|
|
|
+ try {
|
|
|
+ if (kind == AttachmentContentRouter.Kind.TABLE) {
|
|
|
+ extractTable(att, target, safeName);
|
|
|
+ } else {
|
|
|
+ extractDocument(att, target);
|
|
|
+ }
|
|
|
+ att.setStatus("READY");
|
|
|
+ } catch (Exception e) {
|
|
|
+ att.setFailReason(StrUtil.maxLength(e.getMessage(), 900));
|
|
|
+ log.warn("附件提取失败: name={}, err={}", safeName, e.getMessage());
|
|
|
+ }
|
|
|
+ attachmentMapper.insert(att);
|
|
|
+ return toVO(att);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 表格:一次流式扫描(行数/表头/样本);xlsx 顺写 sidecar csv 统一给 pandas 读 */
|
|
|
+ private void extractTable(ChatAttachment att, Path target, String safeName) {
|
|
|
+ String suffix = AttachmentContentRouter.suffixOf(safeName);
|
|
|
+ AttachmentSheetScanner.SheetScan scan;
|
|
|
+ if ("csv".equals(suffix)) {
|
|
|
+ scan = AttachmentSheetScanner.scanCsv(target.toFile(), props.getSampleRows());
|
|
|
+ // GBK 等非 UTF-8 csv:pandas 默认 UTF-8 会读炸,agent 侧用原路径即可(指引里提示可换 encoding);
|
|
|
+ // 为省一条链路不另转码 —— Agent 的 execute_python 能自己迭代 encoding 参数
|
|
|
+ } else {
|
|
|
+ Path sidecar = sidecarPath(target, ".csv");
|
|
|
+ scan = AttachmentSheetScanner.scanExcel(target.toFile(), props.getSampleRows(), sidecar.toFile());
|
|
|
+ att.setSidecarPath(scan.readError() == null ? sidecar.toString() : null);
|
|
|
+ }
|
|
|
+ att.setRowCount(scan.rowCount());
|
|
|
+ att.setCharCount(0);
|
|
|
+ if (scan.rowCount() <= props.getInlineMaxRows() && scan.readError() == null) {
|
|
|
+ // 小表:markdown 全文内联
|
|
|
+ att.setInlineText(buildSheetMarkdown(scan, scan.rowCount()));
|
|
|
+ }
|
|
|
+ if (scan.readError() != null) {
|
|
|
+ throw new IllegalStateException("表格读取中断(已读 " + scan.rowCount() + " 行): " + scan.readError());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 文档:Tika 提取(writeLimit = RAG 索引上限,超限即截断),小文档同时内联 */
|
|
|
+ private void extractDocument(ChatAttachment att, Path target) throws Exception {
|
|
|
+ Path sidecar = sidecarPath(target, ".txt");
|
|
|
+ long maxChars = (long) props.getMaxChunks() * props.getChunkSize();
|
|
|
+ int limit = (int) Math.min(maxChars + 1, Integer.MAX_VALUE);
|
|
|
+ BodyContentHandler handler = new BodyContentHandler(limit);
|
|
|
+ boolean truncated = false;
|
|
|
+ // writeLimit 超限时 Tika 抛 WriteLimitReachedException 中断解析,已提取的内容仍在 handler 里
|
|
|
+ try (var in = Files.newInputStream(target)) {
|
|
|
+ new AutoDetectParser().parse(in, handler, new Metadata());
|
|
|
+ } catch (Exception e) {
|
|
|
+ if (handler.toString().isEmpty()) {
|
|
|
+ throw new IllegalStateException("文档文本提取失败: " + e.getMessage());
|
|
|
+ }
|
|
|
+ truncated = true;
|
|
|
+ }
|
|
|
+ String full = handler.toString();
|
|
|
+ Files.writeString(sidecar, full, StandardCharsets.UTF_8);
|
|
|
+ att.setCharCount(full.length());
|
|
|
+ att.setSidecarPath(sidecar.toString());
|
|
|
+ if (truncated || full.length() >= maxChars) {
|
|
|
+ att.setTruncated(1);
|
|
|
+ }
|
|
|
+ if (full.length() <= props.getInlineMaxChars()) {
|
|
|
+ // 小文档:全文内联
|
|
|
+ att.setInlineText(full);
|
|
|
+ } else {
|
|
|
+ // 大文档:同步 RAG 索引(超块数的部分由 indexer 截断并标注)
|
|
|
+ try {
|
|
|
+ AttachmentDocIndexer.IndexResult r = docIndexer.index(full, att.getId(), att.getSessionId(), att.getCaseId());
|
|
|
+ att.setIndexBlocks(r.chunks());
|
|
|
+ att.setIndexStatus("READY");
|
|
|
+ if (r.truncated()) {
|
|
|
+ att.setTruncated(1);
|
|
|
+ }
|
|
|
+ } catch (IllegalStateException e) {
|
|
|
+ att.setIndexStatus("FAILED");
|
|
|
+ throw e;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 发送时加载(三重归属校验) ====================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 为一条消息组装附件注入块(每附件一段文本,doStream 拼进 USER Msg)。
|
|
|
+ *
|
|
|
+ * @param question 用户问题(RAG 检索用)
|
|
|
+ * @throws ServerException 附件不存在或归属不符(跨用户/跨案件/跨会话一律拒绝)
|
|
|
+ */
|
|
|
+ public List<String> buildBlocks(List<Long> attachmentIds, Long userId, Long caseId, Long sessionId, String question) {
|
|
|
+ if (attachmentIds == null || attachmentIds.isEmpty()) {
|
|
|
+ return List.of();
|
|
|
+ }
|
|
|
+ List<String> blocks = new ArrayList<>(attachmentIds.size());
|
|
|
+ int n = 0;
|
|
|
+ for (Long id : attachmentIds) {
|
|
|
+ ChatAttachment att = attachmentMapper.selectById(id);
|
|
|
+ if (att == null) {
|
|
|
+ throw new ServerException(400, "附件不存在或已清理: " + id);
|
|
|
+ }
|
|
|
+ if (att.getUserId() == null || !att.getUserId().equals(userId)
|
|
|
+ || att.getCaseId() == null || !att.getCaseId().equals(caseId)) {
|
|
|
+ // 归属校验:跨用户/跨案件读附件是实打实的洞,直接拒绝
|
|
|
+ throw new ServerException(400, "附件不属于当前案件,拒绝访问: " + att.getFileName());
|
|
|
+ }
|
|
|
+ if (att.getSessionId() == null || att.getSessionId() == 0L) {
|
|
|
+ // 草稿期上传的附件:发送时绑定到当前会话(此后按会话归属校验)
|
|
|
+ attachmentMapper.update(null, Wrappers.<ChatAttachment>lambdaUpdate()
|
|
|
+ .eq(ChatAttachment::getId, att.getId())
|
|
|
+ .set(ChatAttachment::getSessionId, sessionId)
|
|
|
+ .set(ChatAttachment::getUpdateTime, LocalDateTime.now()));
|
|
|
+ att.setSessionId(sessionId);
|
|
|
+ } else if (!att.getSessionId().equals(sessionId)) {
|
|
|
+ throw new ServerException(400, "附件不属于当前会话,拒绝访问: " + att.getFileName());
|
|
|
+ }
|
|
|
+ if (!"READY".equals(att.getStatus())) {
|
|
|
+ throw new ServerException(400, "附件处理失败,无法使用: " + att.getFileName()
|
|
|
+ + "(" + StrUtil.blankToDefault(att.getFailReason(), "未知原因") + ")");
|
|
|
+ }
|
|
|
+ n++;
|
|
|
+ blocks.add(buildBlock(att, n, attachmentIds.size(), question));
|
|
|
+ }
|
|
|
+ return blocks;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String buildBlock(ChatAttachment att, int no, int total, String question) {
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ String head = "【附件 " + no + "/" + total + ":" + att.getFileName() + "】";
|
|
|
+ if (att.getInlineText() != null && !att.getInlineText().isBlank()) {
|
|
|
+ // 路径 A:全文/全表内联
|
|
|
+ String label = "TABLE".equals(att.getKind())
|
|
|
+ ? "表格, " + att.getRowCount() + " 行"
|
|
|
+ : "文档, " + att.getCharCount() + " 字" + (att.getTruncated() == 1 ? ", 已截断" : "");
|
|
|
+ sb.append(head).append("(").append(label).append(")\n");
|
|
|
+ sb.append("<<<ATTACHMENT_START>>>\n").append(att.getInlineText()).append("\n<<<ATTACHMENT_END>>>\n");
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+ if ("TABLE".equals(att.getKind())) {
|
|
|
+ // 路径 B:Python 直读(不截断不算错)
|
|
|
+ String dataPath = StrUtil.blankToDefault(att.getSidecarPath(), att.getFilePath());
|
|
|
+ sb.append(head).append("(表格, ").append(att.getRowCount()).append(" 行)\n");
|
|
|
+ sb.append("数据文件路径:").append(dataPath).append('\n');
|
|
|
+ sb.append("列:").append(att.getFileName()).append(" 的表头见下方样本。\n");
|
|
|
+ sb.append("【重要】计算/统计类问题必须用 execute_python 读该文件全量计算(示例:"
|
|
|
+ + "import pandas as pd; df = pd.read_csv(r'" + dataPath + "'); ...),"
|
|
|
+ + "不要只依据样本行估算");
|
|
|
+ if (att.getSidecarPath() != null && !att.getSidecarPath().equals(att.getFilePath())) {
|
|
|
+ sb.append("(已转为 UTF-8 csv,直接 read_csv 即可)");
|
|
|
+ }
|
|
|
+ sb.append("。样本行仅供理解结构:\n");
|
|
|
+ sb.append(buildSamplePreview(att));
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+ // 路径 C:RAG 检索片段
|
|
|
+ List<String> fragments = docIndexer.search(question, att.getId(), att.getSessionId(), att.getCaseId());
|
|
|
+ sb.append(head).append("(文档, ").append(att.getCharCount()).append(" 字, 已切片索引 ")
|
|
|
+ .append(att.getIndexBlocks()).append(" 片").append(att.getTruncated() == 1 ? ", 仅索引前部" : "")
|
|
|
+ .append(")\n");
|
|
|
+ if (fragments.isEmpty()) {
|
|
|
+ sb.append("(未检索到与问题相关的片段,请根据问题要求用户提供更精确的关键词)\n");
|
|
|
+ } else {
|
|
|
+ sb.append("【相关片段】\n");
|
|
|
+ int i = 0;
|
|
|
+ for (String f : fragments) {
|
|
|
+ sb.append("片段 ").append(++i).append(":\n").append(f).append('\n');
|
|
|
+ }
|
|
|
+ sb.append("以上是与问题最相关的原文片段,回答时引用片段内容");
|
|
|
+ if (att.getTruncated() == 1) {
|
|
|
+ sb.append(";注意:该文件仅前部已索引");
|
|
|
+ }
|
|
|
+ sb.append("。");
|
|
|
+ }
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 表格样本预览(markdown,前 sampleRows 行) */
|
|
|
+ private String buildSamplePreview(ChatAttachment att) {
|
|
|
+ AttachmentSheetScanner.SheetScan scan = "csv".equals(AttachmentContentRouter.suffixOf(att.getFileName()))
|
|
|
+ ? AttachmentSheetScanner.scanCsv(new File(att.getFilePath()), props.getSampleRows())
|
|
|
+ : AttachmentSheetScanner.scanCsv(new File(StrUtil.blankToDefault(att.getSidecarPath(), att.getFilePath())), props.getSampleRows());
|
|
|
+ return buildSheetMarkdown(scan, props.getSampleRows()) + "\n(共 " + att.getRowCount() + " 行,"
|
|
|
+ + "完整数据请用 execute_python 读取文件)";
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String buildSheetMarkdown(AttachmentSheetScanner.SheetScan scan, int maxRows) {
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ sb.append("| ").append(String.join(" | ", scan.headers())).append(" |\n");
|
|
|
+ sb.append("|").append("---|".repeat(Math.max(1, scan.headers().size()))).append('\n');
|
|
|
+ for (List<String> row : scan.samples().stream().limit(maxRows).toList()) {
|
|
|
+ sb.append("| ");
|
|
|
+ for (int i = 0; i < scan.headers().size(); i++) {
|
|
|
+ String v = i < row.size() && row.get(i) != null ? row.get(i).replace("|", "\\|") : "";
|
|
|
+ sb.append(v).append(" | ");
|
|
|
+ }
|
|
|
+ sb.append('\n');
|
|
|
+ }
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 消息发出后回填关联 id(孤儿清理依据) */
|
|
|
+ public void linkToMessage(List<Long> attachmentIds, Long messageId) {
|
|
|
+ if (attachmentIds == null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ for (Long id : attachmentIds) {
|
|
|
+ attachmentMapper.update(null, Wrappers.<ChatAttachment>lambdaUpdate()
|
|
|
+ .eq(ChatAttachment::getId, id)
|
|
|
+ .set(ChatAttachment::getLinkMessageId, messageId)
|
|
|
+ .set(ChatAttachment::getUpdateTime, LocalDateTime.now()));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 消息摘要元数据(徽标持久化用) */
|
|
|
+ public List<Map<String, Object>> badgeMeta(List<Long> attachmentIds, Long userId, Long caseId, Long sessionId) {
|
|
|
+ List<Map<String, Object>> out = new ArrayList<>();
|
|
|
+ if (attachmentIds == null) {
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+ for (Long id : attachmentIds) {
|
|
|
+ ChatAttachment att = attachmentMapper.selectById(id);
|
|
|
+ if (att == null || !att.getUserId().equals(userId) || !att.getCaseId().equals(caseId)
|
|
|
+ || !att.getSessionId().equals(sessionId)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ Map<String, Object> m = new LinkedHashMap<>();
|
|
|
+ m.put("id", att.getId());
|
|
|
+ m.put("name", att.getFileName());
|
|
|
+ m.put("size", att.getSizeBytes());
|
|
|
+ m.put("kind", att.getKind());
|
|
|
+ m.put("rows", att.getRowCount());
|
|
|
+ m.put("chars", att.getCharCount());
|
|
|
+ m.put("truncated", att.getTruncated() == 1);
|
|
|
+ m.put("indexedChunks", att.getIndexBlocks());
|
|
|
+ out.add(m);
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 清理 ====================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 孤儿清理:24h 前上传且从未关联消息的附件 → 删文件 + sidecar + 向量行。
|
|
|
+ * 已关联的随会话留存(级联删除列为后续)。
|
|
|
+ */
|
|
|
+ @Scheduled(fixedDelayString = "${zsjz.chat-attachment.purge-interval-ms:3600000}",
|
|
|
+ initialDelayString = "${zsjz.chat-attachment.purge-initial-delay-ms:600000}")
|
|
|
+ public void purgeOrphans() {
|
|
|
+ LocalDateTime deadline = LocalDateTime.now().minusHours(24);
|
|
|
+ List<ChatAttachment> orphans = attachmentMapper.selectList(Wrappers.<ChatAttachment>lambdaQuery()
|
|
|
+ .isNull(ChatAttachment::getLinkMessageId)
|
|
|
+ .lt(ChatAttachment::getCreateTime, deadline));
|
|
|
+ for (ChatAttachment att : orphans) {
|
|
|
+ try {
|
|
|
+ docIndexer.deleteAttachmentVectors(att.getId(), att.getCaseId());
|
|
|
+ deleteQuietly(att.getFilePath());
|
|
|
+ deleteQuietly(att.getSidecarPath());
|
|
|
+ attachmentMapper.deleteById(att.getId());
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("孤儿附件清理失败: id={}, err={}", att.getId(), e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!orphans.isEmpty()) {
|
|
|
+ log.info("孤儿附件清理完成: {} 个", orphans.size());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void deleteQuietly(String path) {
|
|
|
+ if (path != null && !path.isBlank()) {
|
|
|
+ FileUtil.del(path);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 内部 ====================
|
|
|
+
|
|
|
+ private static AttachmentVO toVO(ChatAttachment att) {
|
|
|
+ return new AttachmentVO(att.getId(), att.getFileName(),
|
|
|
+ att.getSizeBytes() == null ? 0 : att.getSizeBytes(), att.getKind(),
|
|
|
+ att.getRowCount() == null ? 0 : att.getRowCount(),
|
|
|
+ att.getCharCount() == null ? 0 : att.getCharCount(),
|
|
|
+ att.getTruncated() != null && att.getTruncated() == 1,
|
|
|
+ att.getIndexBlocks() == null ? 0 : att.getIndexBlocks(),
|
|
|
+ att.getStatus());
|
|
|
+ }
|
|
|
+
|
|
|
+ private Path baseDir(Long userId, Long caseId, Long sessionId) {
|
|
|
+ return Paths.get(System.getProperty("user.dir"), "QingJian", "chat-attachments",
|
|
|
+ String.valueOf(userId), String.valueOf(caseId), String.valueOf(sessionId))
|
|
|
+ .toAbsolutePath().normalize();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Path sidecarPath(Path target, String ext) {
|
|
|
+ String name = target.getFileName().toString();
|
|
|
+ int dot = name.lastIndexOf('.');
|
|
|
+ return target.resolveSibling((dot < 0 ? name : name.substring(0, dot)) + ext);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 文件名 sanitize(与 DmService#saveUploadedFile 同手法) */
|
|
|
+ static String sanitizeFileName(String original) {
|
|
|
+ String name = FileUtil.getName(StrUtil.blankToDefault(original, "attachment"));
|
|
|
+ name = name.replaceAll("[\\\\/:*?\"<>|]", "_");
|
|
|
+ return StrUtil.isBlank(name) ? "attachment" : name;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String detectMime(MultipartFile file, String safeName) {
|
|
|
+ try {
|
|
|
+ return tika.detect(file.getInputStream(), safeName);
|
|
|
+ } catch (Exception e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 供 Controller 用的 VO 序列化(前端 chips 展示) */
|
|
|
+ public static String toJson(AttachmentVO vo) {
|
|
|
+ return Json.toStr(vo);
|
|
|
+ }
|
|
|
+}
|