Переглянути джерело

feat: 输入框附件问答(内联/RAG/Python直读三路 + 三层数据隔离)

后端 module/agent/attachment:
- 三路路由(AttachmentContentRouter):小内容内联进消息(Claude document
  block 同款);大表格流式扫描(行数/表头/样本)后注入文件路径 + 指引,
  Agent 用 execute_python pd.read_csv 全量计算(100MB csv 不截断不算错);
  大文档 Tika 提取→分块→embedding→pgvector,按用户问题检索 top-8 片段注入
- AttachmentSheetScanner:csv commons-csv 流式(引号/编码对齐 PreTask),
  xlsx 复用 AiRowReader.forEachRow 顺写 sidecar csv(统一 pandas 读法)
- AttachmentDocIndexer:分块(1000/重叠100/上限500) + 并发 embedding
  (EmbeddingModelFactory 同 RagSchemaService)+ 会话过滤检索
- ChatAttachmentService:上传编排/三重归属校验(userId/caseId/sessionId,
  草稿期附件发送时绑定)/孤儿清理(24h);sanitize 文件名防穿越
- doStream 注入「附件块+问题」单条 USER Msg;用户消息落原文+metadata 徽标
- POST /chat/attachments 上传端点;ChatRequestDTO.attachmentIds

前端:
- ChatComposer 附件按钮+chips(选择即上传/进度/失败移除),发送载荷带
  attachmentIds+徽标;用户气泡附件徽标;hydrate 从 metadata 还原
- 雪花 ID 全链路字符串(修复 Long 序列化精度问题)

其他:sql/chat_attachment.sql DDL(已执行);zsjz.chat-attachment 配置段;
单测 16/16(Router/Scanner/分块/归属隔离/注入块),全仓回归零新增
cc 1 тиждень тому
батько
коміт
b701d3d672
23 змінених файлів з 2019 додано та 20 видалено
  1. 41 0
      ai-electron/frontend/src/ai/api/attachmentApi.ts
  2. 38 0
      ai-electron/frontend/src/ai/api/types.ts
  3. 200 4
      ai-electron/frontend/src/ai/components/ChatComposer.vue
  4. 50 0
      ai-electron/frontend/src/ai/components/ChatMessageItem.vue
  5. 8 3
      ai-electron/frontend/src/ai/components/ChatPanel.vue
  6. 13 4
      ai-electron/frontend/src/ai/composables/useAiChatPage.ts
  7. 21 3
      ai-electron/frontend/src/ai/store/chatStream.ts
  8. 94 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/attachment/AttachmentContentRouter.java
  9. 216 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/attachment/AttachmentDocIndexer.java
  10. 193 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/attachment/AttachmentSheetScanner.java
  11. 56 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/attachment/AttachmentVectorMapper.java
  12. 105 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/attachment/ChatAttachment.java
  13. 11 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/attachment/ChatAttachmentMapper.java
  14. 81 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/attachment/ChatAttachmentProperties.java
  15. 439 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/attachment/ChatAttachmentService.java
  16. 24 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/controller/AgentChatController.java
  17. 9 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/dto/ChatRequestDTO.java
  18. 42 6
      ai-server/src/main/java/com/zsjz/ai/module/agent/service/impl/AgentChatServiceImpl.java
  19. 20 0
      ai-server/src/main/resources/application.yaml
  20. 76 0
      ai-server/src/test/java/com/zsjz/ai/module/agent/attachment/AttachmentContentRouterTest.java
  21. 78 0
      ai-server/src/test/java/com/zsjz/ai/module/agent/attachment/AttachmentSheetScannerTest.java
  22. 165 0
      ai-server/src/test/java/com/zsjz/ai/module/agent/attachment/ChatAttachmentServiceTest.java
  23. 39 0
      sql/chat_attachment.sql

+ 41 - 0
ai-electron/frontend/src/ai/api/attachmentApi.ts

@@ -0,0 +1,41 @@
+/**
+ * 对话附件上传(输入框附件功能)。
+ *
+ * 选择即上传:multipart 发到 {@code POST /chat/attachments}(workspaceId 必传、sessionId 可空),
+ * 返回附件元数据供 chips 展示与发送时引用。上传即提取:
+ *  - 小文档/小表格 → 内联进消息;
+ *  - 大表格 → 服务端只流式扫描(行数/表头/样本),发送时由 Agent 用 execute_python 直读文件全量计算;
+ *  - 大文档 → 向量化(RAG),发送时按问题检索片段注入。
+ */
+import { defHttp } from '@/core/utils/http/axios';
+import { resolveApiUrl } from '../utils/constants';
+import type { AttachmentVO } from './types';
+
+/**
+ * 上传附件。注意 defHttp.uploadFile 不走自动解包(见 governApi 的同类说明),
+ * 业务数据在 res.data.data,这里统一剥壳给调用方。
+ */
+export async function uploadAttachment(
+  file: File,
+  params: { workspaceId: string; sessionId?: string | null },
+  onProgress?: (percent: number) => void,
+): Promise<AttachmentVO> {
+  const res: any = await defHttp.uploadFile<any>(
+    {
+      url: resolveApiUrl('/chat/attachments'),
+      onUploadProgress: (e: { loaded: number; total?: number }) => {
+        if (e.total) onProgress?.(Math.round((e.loaded * 100) / e.total));
+      },
+    },
+    {
+      name: 'file',
+      file,
+      filename: file.name,
+      data: {
+        workspaceId: params.workspaceId,
+        ...(params.sessionId ? { sessionId: params.sessionId } : {}),
+      },
+    },
+  );
+  return (res?.data?.data ?? res?.data ?? res) as AttachmentVO;
+}

+ 38 - 0
ai-electron/frontend/src/ai/api/types.ts

@@ -165,6 +165,44 @@ export interface ChatStreamParams {
   agentId?: string;
   /** 动态切换模型(可选,会回写会话绑定)—— 雪花 ID,必须按字符串传 */
   modelId?: string;
+  /** 附件 ID 列表(可选;雪花 ID 字符串,后端 List<Long> 接收) */
+  attachmentIds?: string[];
+}
+
+/* ------------------------------------------------------------------ *
+ * 对话附件(输入框附件功能)
+ * ------------------------------------------------------------------ */
+
+/** 附件上传返回(chips 展示 + 发送引用) */
+export interface AttachmentVO {
+  /** 雪花 ID —— 后端 Long 以字符串下发(精度保护),全程按字符串传,禁止 Number() 转换 */
+  id: string;
+  name: string;
+  size: number;
+  /** DOCUMENT / TABLE */
+  kind: string;
+  /** 表格数据行数 */
+  rows: number;
+  /** 文档字符数 */
+  chars: number;
+  /** 内容已截断(文档超限 / 索引超块数) */
+  truncated: boolean;
+  /** 大文档已向量化块数 */
+  indexedChunks: number;
+  /** READY / FAILED */
+  status: string;
+}
+
+/** 消息附件徽标(用户气泡展示;与后端消息 metadata.attachments 同构) */
+export interface AttachmentBadge {
+  id: string;
+  name: string;
+  size?: number;
+  kind?: string;
+  rows?: number;
+  chars?: number;
+  truncated?: boolean;
+  indexedChunks?: number;
 }
 
 /* ------------------------------------------------------------------ *

+ 200 - 4
ai-electron/frontend/src/ai/components/ChatComposer.vue

@@ -15,6 +15,23 @@
         @press-enter="onEnter"
       />
 
+      <!-- 附件 chips:选择即上传,上传中显进度、失败可移除 -->
+      <div v-if="attachments.length" class="ai-composer__atts">
+        <span v-for="att in attachments" :key="att.localId" class="ai-att" :class="{ 'is-failed': att.failed }">
+          <Icon :icon="att.icon" :size="13" />
+          <span class="ai-att__name" :title="att.name">{{ att.name }}</span>
+          <span class="ai-att__meta">{{ att.meta }}</span>
+          <span v-if="att.uploading" class="ai-att__progress">{{ att.progress }}%</span>
+          <Icon
+            v-else
+            class="ai-att__remove"
+            icon="mdi:close-circle-outline"
+            :size="13"
+            @click="removeAttachment(att.localId)"
+          />
+        </span>
+      </div>
+
       <div class="ai-composer__bar">
         <!-- 专家 chip(只读):草稿态显示将绑定的专家,会话已建则显示绑定者 -->
         <span v-if="expertName" class="ai-composer__expert" :title="expertTitle">
@@ -42,6 +59,25 @@
           模板
         </button>
 
+        <!-- 附件:选择即上传(文档/表格,图片音视频不支持) -->
+        <button
+          class="ai-composer__tpl"
+          type="button"
+          title="添加附件(文档/表格,只针对本次提问)"
+          :disabled="disabled || streaming"
+          @click="pickFile"
+        >
+          <Icon icon="mdi:paperclip" :size="13" />
+          附件
+        </button>
+        <input
+          ref="fileRef"
+          class="ai-composer__file"
+          type="file"
+          accept=".txt,.md,.log,.json,.xml,.yaml,.yml,.ini,.sql,.tsv,.pdf,.doc,.docx,.rtf,.odt,.wps,.html,.htm,.eml,.csv,.xls,.xlsx"
+          @change="onFilePicked"
+        />
+
         <span class="ai-composer__spacer"></span>
 
         <!-- 发送 / 停止:同一个位置、同一个尺寸,流式切换时不产生跳动 -->
@@ -84,7 +120,10 @@
   import { computed, nextTick, ref, watch } from 'vue';
   import { Dropdown, Menu, MenuItem, Textarea } from 'ant-design-vue';
   import Icon from '@/core/components/Icon/src/Icon.vue';
-  import type { AiModel } from '../api/types';
+  import { useUserStore } from '@/core/store/modules/case';
+  import { uploadAttachment } from '../api/attachmentApi';
+  import type { AiModel, AttachmentBadge, AttachmentVO } from '../api/types';
+  import { useChatStreamStore } from '../store/chatStream';
 
   const ATextarea = Textarea;
   const ADropdown = Dropdown;
@@ -114,7 +153,7 @@
   );
 
   const emit = defineEmits<{
-    send: [text: string];
+    send: [payload: { text: string; attachmentIds: string[]; badges: AttachmentBadge[] }];
     stop: [];
     'update:modelId': [id: string];
     /** 打开分析模板抽屉 */
@@ -124,6 +163,98 @@
   const draft = ref('');
   const focused = ref(false);
   const inputRef = ref<any>();
+  const fileRef = ref<HTMLInputElement>();
+  const userStore = useUserStore();
+  const chatStore = useChatStreamStore();
+
+  /* ---------------- 附件(选择即上传) ---------------- */
+
+  interface AttChip {
+    localId: number;
+    /** 上传成功后的附件 id(雪花字符串,原样回传后端,禁止 Number() 转换) */
+    id?: string;
+    name: string;
+    meta: string;
+    icon: string;
+    uploading: boolean;
+    progress: number;
+    failed: boolean;
+    /** 上传返回的完整摘要(发送时带作徽标) */
+    vo?: AttachmentVO;
+  }
+
+  const attachments = ref<AttChip[]>([]);
+  let attSeq = 0;
+
+  function pickFile() {
+    fileRef.value?.click();
+  }
+
+  async function onFilePicked(e: Event) {
+    const input = e.target as HTMLInputElement;
+    const file = input.files?.[0];
+    // 先清空再取值,保证连续选同一文件也能触发 change
+    input.value = '';
+    if (!file) return;
+    const chip: AttChip = {
+      localId: ++attSeq,
+      name: file.name,
+      meta: formatSize(file.size),
+      icon: iconFor(file.name),
+      uploading: true,
+      progress: 0,
+      failed: false,
+    };
+    attachments.value.push(chip);
+    try {
+      const vo = await uploadAttachment(
+        file,
+        { workspaceId: String(userStore.getCaseInfo?.id ?? ''), sessionId: chatStore.activeSessionId ?? undefined },
+        (p) => {
+          chip.progress = p;
+        },
+      );
+      if (vo.status !== 'READY') {
+        throw new Error('附件处理失败,请重试');
+      }
+      chip.id = vo.id;
+      chip.vo = vo;
+      chip.uploading = false;
+      chip.meta = metaOf(vo);
+      chip.icon = iconFor(vo.name);
+    } catch (err: any) {
+      chip.uploading = false;
+      chip.failed = true;
+      chip.meta = err?.response?.data?.message || err?.message || '上传失败,点击 × 移除';
+    }
+  }
+
+  function removeAttachment(localId: number) {
+    attachments.value = attachments.value.filter((a) => a.localId !== localId);
+  }
+
+  function metaOf(vo: AttachmentVO) {
+    const base =
+      vo.kind === 'TABLE'
+        ? `${formatSize(vo.size)} · ${vo.rows} 行`
+        : `${formatSize(vo.size)} · ${vo.chars} 字`;
+    const extra = vo.indexedChunks > 0 ? ` · 已索引 ${vo.indexedChunks} 片` : vo.truncated ? ' · 已截断' : '';
+    return base + extra;
+  }
+
+  function iconFor(name: string) {
+    const ext = (name.split('.').pop() || '').toLowerCase();
+    if (['xls', 'xlsx', 'csv'].includes(ext)) return 'mdi:file-table-outline';
+    if (['pdf'].includes(ext)) return 'mdi:file-pdf-box';
+    if (['doc', 'docx'].includes(ext)) return 'mdi:file-word-outline';
+    return 'mdi:file-document-outline';
+  }
+
+  function formatSize(bytes: number) {
+    if (bytes < 1024) return `${bytes} B`;
+    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+    return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+  }
 
   const modelOptions = computed(() =>
     (props.models || []).map((m) => ({
@@ -139,7 +270,9 @@
 
   const selectedKeys = computed(() => (props.modelId ? [String(props.modelId)] : []));
 
-  const canSend = computed(() => !!draft.value.trim());
+  const canSend = computed(
+    () => !!draft.value.trim() && !attachments.value.some((a) => a.uploading),
+  );
 
   /**
    * Menu 的 key 一律是字符串 —— 雪花 ID 绝不能在这里做 Number() 转换,
@@ -153,8 +286,15 @@
   function submit() {
     const text = draft.value.trim();
     if (!text || props.disabled || props.streaming) return;
+    // 上传中的附件不随发(canSend 已拦截);失败的自动剔除,只发成功的
+    const atts = attachments.value.filter((a) => !a.failed && !a.uploading && a.id != null && a.vo);
     draft.value = '';
-    emit('send', text);
+    attachments.value = [];
+    emit('send', {
+      text,
+      attachmentIds: atts.map((a) => a.id!),
+      badges: atts.map((a) => a.vo!),
+    });
   }
 
   /** 中文输入法组合中的 Enter 不应触发发送 */
@@ -336,6 +476,62 @@
     }
   }
 
+  /* 附件 chips:textarea 与操作行之间一行小胶囊 */
+  .ai-composer__atts {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+    padding: 4px 2px 0;
+
+    .ai-att {
+      display: inline-flex;
+      align-items: center;
+      gap: 5px;
+      max-width: 260px;
+      height: 24px;
+      padding: 0 8px;
+      font-size: 11.5px;
+      color: var(--ai-text);
+      background: var(--ai-bg-subtle);
+      border: 1px solid var(--ai-border);
+      border-radius: 12px;
+
+      &.is-failed {
+        color: @error-color;
+        border-color: fade(@error-color, 35%);
+      }
+
+      &__name {
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+      }
+
+      &__meta {
+        color: var(--ai-text-2);
+        white-space: nowrap;
+      }
+
+      &__progress {
+        color: var(--ai-primary);
+        white-space: nowrap;
+      }
+
+      &__remove {
+        cursor: pointer;
+
+        &:hover {
+          color: @error-color;
+        }
+      }
+    }
+  }
+
+  /* 文件选择用按钮触发,原生 input 隐藏 */
+  .ai-composer__file {
+    display: none;
+  }
+
   .ai-composer__spacer {
     flex: 1 1 auto;
   }

+ 50 - 0
ai-electron/frontend/src/ai/components/ChatMessageItem.vue

@@ -8,6 +8,15 @@
     <template v-if="isUser">
       <p class="ai-msg__bubble">{{ message.content }}</p>
 
+      <!-- 附件徽标:本次提问随附的文件(落库后从 metadata.attachments 还原) -->
+      <div v-if="message.attachments?.length" class="ai-msg__atts">
+        <span v-for="att in message.attachments" :key="att.id" class="ai-msg__att">
+          <Icon icon="mdi:paperclip" :size="11" />
+          <span class="ai-msg__att-name" :title="att.name">{{ att.name }}</span>
+          <span class="ai-msg__att-meta">{{ badgeMeta(att) }}</span>
+        </span>
+      </div>
+
       <div class="ai-msg__foot">
         <span v-if="message.messageType === 'interrupted'" class="ai-msg__flag">已中断</span>
         <span v-if="timeText" class="ai-msg__time">{{ timeText }}</span>
@@ -138,6 +147,13 @@
 
   const isUser = computed(() => props.message.role === 'user');
 
+  /** 附件徽标摘要文本:表格显示行数、文档显示字数,截断/索引有标 */
+  function badgeMeta(att: import('../api/types').AttachmentBadge) {
+    const base = att.kind === 'TABLE' ? `${att.rows ?? 0} 行` : `${att.chars ?? 0} 字`;
+    const extra = att.indexedChunks ? ` · ${att.indexedChunks} 片` : att.truncated ? ' · 已截断' : '';
+    return base + extra;
+  }
+
   const hasContent = computed(() => !!(props.message.blocks || []).some((b) => b.text?.trim()));
 
   /** 是否存在过程记录(有思考或有工具调用)——决定是否显示时间线、是否还需要独立阶段提示 */
@@ -172,6 +188,40 @@
       align-items: flex-end;
     }
 
+    /* 附件徽标:气泡下方一行小胶囊 */
+    &__atts {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 4px;
+      justify-content: flex-end;
+      margin-top: 4px;
+    }
+
+    &__att {
+      display: inline-flex;
+      align-items: center;
+      gap: 4px;
+      max-width: 240px;
+      height: 20px;
+      padding: 0 7px;
+      font-size: 11px;
+      color: var(--ai-text-2);
+      background: var(--ai-bg-subtle);
+      border: 1px solid var(--ai-border);
+      border-radius: 10px;
+
+      &-name {
+        overflow: hidden;
+        color: var(--ai-text);
+        white-space: nowrap;
+        text-overflow: ellipsis;
+      }
+
+      &-meta {
+        white-space: nowrap;
+      }
+    }
+
     &__avatar {
       display: flex;
       align-items: center;

+ 8 - 3
ai-electron/frontend/src/ai/components/ChatPanel.vue

@@ -122,7 +122,7 @@
   );
 
   const emit = defineEmits<{
-    send: [text: string];
+    send: [payload: { text: string; attachmentIds: string[]; badges: import('../api/types').AttachmentBadge[] }];
     stop: [];
     star: [id: string];
     remove: [id: string];
@@ -184,13 +184,18 @@
     () => toBottom(),
   );
 
-  /** 重新生成:面板只负责定位到该回答之前最近的一次提问,重发由壳层执行 */
+  /** 重新生成:面板只负责定位到该回答之前最近的一次提问,重发由壳层执行(带原附件) */
   function onRegenerate(index: number) {
     if (props.streaming) return;
     for (let i = index - 1; i >= 0; i -= 1) {
       const message = props.messages[i];
       if (message.role === 'user') {
-        emit('send', message.content);
+        const badges = message.attachments || [];
+        emit('send', {
+          text: message.content,
+          attachmentIds: badges.map((a: any) => a.id).filter(Boolean),
+          badges,
+        });
         return;
       }
     }

+ 13 - 4
ai-electron/frontend/src/ai/composables/useAiChatPage.ts

@@ -158,22 +158,31 @@ export function useAiChatPage() {
 
   /* ---------------- 发送 ---------------- */
 
-  async function onSend(text: string) {
+  async function onSend(payload: {
+    text: string;
+    attachmentIds: string[];
+    badges: import('../api/types').AttachmentBadge[];
+  }) {
     panelRef.value?.toBottom(true);
-    await store.send(text, modelId.value ?? undefined);
+    await store.send(payload.text, modelId.value ?? undefined, payload.attachmentIds, payload.badges);
   }
 
   function usePrompt(text: string) {
     panelRef.value?.fill(text);
   }
 
-  /** 重新生成:找到该回答之前最近的一条用户消息重发 */
+  /** 重新生成:找到该回答之前最近的一条用户消息重发(带原附件) */
   async function onRegenerate(index: number) {
     if (store.streaming) return;
     const messages = store.messages;
     for (let i = index - 1; i >= 0; i -= 1) {
       if (messages[i].role === 'user') {
-        await onSend(messages[i].content);
+        const badges = messages[i].attachments || [];
+        await onSend({
+          text: messages[i].content,
+          attachmentIds: badges.map((a) => a.id).filter(Boolean),
+          badges,
+        });
         return;
       }
     }

+ 21 - 3
ai-electron/frontend/src/ai/store/chatStream.ts

@@ -63,6 +63,8 @@ export interface ChatMessage extends ChatMessageVO {
   suggestions?: string[];
   /** 是否正在流式输出 */
   streaming?: boolean;
+  /** 附件徽标(用户气泡展示;落库后从 metadata.attachments 还原) */
+  attachments?: import('../api/types').AttachmentBadge[];
 }
 
 /** 模块作用域的流式控制器(同时只允许一路) */
@@ -239,10 +241,20 @@ export const useChatStreamStore = defineStore('ai-chat-stream', {
     hydrateMessage(m: ChatMessageVO): ChatMessage {
       const toolSteps = parseToolEvents(m.toolEvents);
       const thinking = parseThinkingFromMetadata(m.metadata);
+      // 附件徽标:metadata.attachments(persistUserMessage 写入的摘要)
+      let attachments: import('../api/types').AttachmentBadge[] | undefined;
+      try {
+        const meta = typeof m.metadata === 'string' ? JSON.parse(m.metadata) : m.metadata;
+        const list = meta?.attachments;
+        if (Array.isArray(list) && list.length) attachments = list;
+      } catch {
+        /* metadata 不是合法 JSON 时忽略徽标 */
+      }
       return {
         ...m,
         toolSteps,
         ...(thinking ? { thinking } : {}),
+        ...(attachments ? { attachments } : {}),
         blocks: parseMessageBlocksIncremental(undefined, m.content || '').blocks,
       };
     },
@@ -278,7 +290,7 @@ export const useChatStreamStore = defineStore('ai-chat-stream', {
      * @param text       用户输入
      * @param modelId    可选:本轮临时切换模型(雪花 ID 字符串,禁止转 number)
      */
-    async send(text: string, modelId?: string) {
+    async send(text: string, modelId?: string, attachmentIds?: string[], badges?: import('../api/types').AttachmentBadge[]) {
       const content = (text || '').trim();
       if (!content || this.streaming || !this.workspaceId) return;
 
@@ -296,7 +308,7 @@ export const useChatStreamStore = defineStore('ai-chat-stream', {
         sessionId = session.id;
       }
 
-      // 2) 乐观插入用户消息
+      // 2) 乐观插入用户消息(带附件徽标,后端落库后由 metadata 兜底还原)
       const userMessage: ChatMessage = {
         id: nextTempId(),
         sessionId,
@@ -305,6 +317,7 @@ export const useChatStreamStore = defineStore('ai-chat-stream', {
         messageType: 'text',
         local: true,
         createAt: new Date().toISOString(),
+        ...(badges && badges.length ? { attachments: badges } : {}),
       };
       // 3) 助手占位消息
       const assistantMessage: ChatMessage = {
@@ -347,7 +360,12 @@ export const useChatStreamStore = defineStore('ai-chat-stream', {
 
       try {
         await chatApi.streamChat(
-          { sessionKey: String(sessionId), message: content, modelId },
+          {
+            sessionKey: String(sessionId),
+            message: content,
+            modelId,
+            ...(attachmentIds && attachmentIds.length ? { attachmentIds } : {}),
+          },
           {
             onIntent: (data) => {
               target.intent = data;

+ 94 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/attachment/AttachmentContentRouter.java

@@ -0,0 +1,94 @@
+package com.zsjz.ai.module.agent.attachment;
+
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * 附件类型路由(f.txt 步骤 2-4 的服务端部分):后缀 + MIME → 处理路径。
+ *
+ * <p>纯函数无状态,判据刻意只用后缀与 MIME 大类(图片/音视频/压缩包/加密二进制
+ * 一律 UNSUPPORTED —— 本版本无 OCR/ASR,提取不出文本的类型给拒绝比给幻觉强)。
+ */
+public final class AttachmentContentRouter {
+
+    /** 表格后缀:流式扫描,小内联 / 大 Python 直读 */
+    private static final Set<String> SHEET_SUFFIXES = Set.of("csv", "xls", "xlsx");
+
+    /** 文档后缀:Tika 提取,小内联 / 大 RAG */
+    private static final Set<String> DOC_SUFFIXES = Set.of(
+            "txt", "md", "log", "json", "xml", "yaml", "yml", "ini", "sql", "tsv",
+            "pdf", "doc", "docx", "rtf", "odt", "wps", "html", "htm", "eml");
+
+    /** 明确拒绝的大类(无 OCR/ASR,见设计边界) */
+    private static final Set<String> UNSUPPORTED_MIME_PREFIXES = Set.of(
+            "image/", "audio/", "video/", "application/zip", "application/x-rar",
+            "application/gzip", "application/x-7z", "application/octet-stream");
+
+    private AttachmentContentRouter() {
+    }
+
+    public enum Kind {
+        /** 文档:Tika 提取文本 */
+        DOCUMENT,
+        /** 表格:流式扫描行数/表头/样本 */
+        TABLE,
+        /** 不支持:图片/音视频/压缩包/加密/未知 */
+        UNSUPPORTED
+    }
+
+    /**
+     * 路由决策。
+     *
+     * @param fileName 原始文件名(后缀判据,空后缀按 UNSUPPORTED)
+     * @param mime     Tika 探测的 MIME(可空,空则只看后缀)
+     */
+    public static Kind route(String fileName, String mime) {
+        String suffix = suffixOf(fileName);
+        // 明确加密的先拦(就算后缀是 docx 也一样,Tika 提不出正文)——
+        // 后缀白名单优先只覆盖「探测不准」的情况,不给加密文件开口子
+        if (mime != null && (mime.toLowerCase(Locale.ROOT).contains("encrypted")
+                || mime.toLowerCase(Locale.ROOT).contains("x-encrypted"))) {
+            return Kind.UNSUPPORTED;
+        }
+        // 后缀白名单优先:即使 MIME 探测为 octet-stream(部分 pdf/自定义文本会这样)也放行,
+        // 提取阶段的 Tika/扫描器自己会失败并报明确原因
+        if (SHEET_SUFFIXES.contains(suffix)) {
+            return Kind.TABLE;
+        }
+        if (DOC_SUFFIXES.contains(suffix)) {
+            return Kind.DOCUMENT;
+        }
+        if (mime != null && unsupportedMime(mime)) {
+            return Kind.UNSUPPORTED;
+        }
+        // 后缀不认识:MIME 是文本大类也放行当文档(.dat 里可能是文本);否则拒绝
+        if (mime != null && mime.startsWith("text/")) {
+            return Kind.DOCUMENT;
+        }
+        return Kind.UNSUPPORTED;
+    }
+
+    /** 后缀(小写、无点);无后缀返回空串 */
+    public static String suffixOf(String fileName) {
+        if (fileName == null) {
+            return "";
+        }
+        int dot = fileName.lastIndexOf('.');
+        return dot < 0 || dot == fileName.length() - 1
+                ? "" : fileName.substring(dot + 1).toLowerCase(Locale.ROOT);
+    }
+
+    private static boolean unsupportedMime(String mime) {
+        String m = mime.toLowerCase(Locale.ROOT).trim();
+        for (String prefix : UNSUPPORTED_MIME_PREFIXES) {
+            if (m.startsWith(prefix)) {
+                // 文本类例外由后缀兜底(如 application/json 后缀 json);这里只拦明确不可解析大类
+                if (prefix.equals("application/octet-stream") && !m.equals("application/octet-stream")) {
+                    continue;
+                }
+                return true;
+            }
+        }
+        return m.contains("encrypted") || m.contains("x-encrypted");
+    }
+}

+ 216 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/attachment/AttachmentDocIndexer.java

@@ -0,0 +1,216 @@
+package com.zsjz.ai.module.agent.attachment;
+
+import com.zsjz.ai.common.utils.Json;
+import com.zsjz.ai.module.agent.rag.EmbeddingModelFactory;
+import io.agentscope.core.message.TextBlock;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Semaphore;
+
+/**
+ * 附件大文档的 RAG 索引(路径 C):分块 → embedding → pgvector。
+ *
+ * <p>与 {@code RagSchemaService} 同一嵌入模型同一维度({@link EmbeddingModelFactory}),
+ * 向量表按案件分表 {@code chat_att_vec_{caseId}_d{dims}},chunk 的 payload 强制带
+ * {@code sessionId + attachmentId} —— 检索侧 WHERE 过滤会话(隔离要求)。
+ *
+ * <p>同步索引(上传请求内完成):块数上限 {@code max-chunks}(默认 500),
+ * 并发 {@code index-concurrency}(默认 8)打满远程 embedding API。
+ * 超出上限的尾部内容截断并在注入块标注「仅索引前 N 字」—— 诚实告知,不静默丢。
+ *
+ * @author cc
+ * @since 2026/9/29
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class AttachmentDocIndexer {
+
+    private final EmbeddingModelFactory embeddingModelFactory;
+
+    private final AttachmentVectorMapper vectorMapper;
+
+    private final ChatAttachmentProperties props;
+
+    /**
+     * 索引结果。
+     *
+     * @param chunks    已向量化块数
+     * @param truncated true = 文本超 max-chunks 上限,仅索引前部
+     */
+    public record IndexResult(int chunks, boolean truncated) {
+    }
+
+    /**
+     * 把 sidecar 全文分块索引进 pgvector。
+     *
+     * @throws IllegalStateException 未配置嵌入模型 / 建表失败(调用方标 FAILED)
+     */
+    public IndexResult index(String fullText, Long attachmentId, Long sessionId, Long caseId) {
+        EmbeddingModelFactory.EmbeddingSpec spec = embeddingModelFactory.resolveDefault();
+        if (spec == null) {
+            throw new IllegalStateException("未配置嵌入模型(model 表需存在 type='embedding' 的活跃记录),无法索引大文档");
+        }
+        String table = vectorTableName(caseId, spec.dimensions());
+        vectorMapper.createVectorTable(table, spec.dimensions());
+
+        List<String> chunks = chunk(fullText, props.getChunkSize(), props.getChunkOverlap());
+        boolean truncated = chunks.size() > props.getMaxChunks();
+        if (truncated) {
+            chunks = chunks.subList(0, props.getMaxChunks());
+        }
+        // 重放幂等:同一附件重索引前清掉旧行(doc_id 唯一键也会兜底)
+        vectorMapper.deleteByAttachment(table, String.valueOf(attachmentId));
+
+        Semaphore gate = new Semaphore(Math.max(1, props.getIndexConcurrency()), true);
+        List<Runnable> jobs = new ArrayList<>(chunks.size());
+        int[] ok = {0};
+        for (int i = 0; i < chunks.size(); i++) {
+            int chunkNo = i;
+            String content = chunks.get(i);
+            jobs.add(() -> {
+                try {
+                    gate.acquire();
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    return;
+                }
+                try {
+                    double[] vec = spec.model()
+                            .embed(TextBlock.builder().text(content).build())
+                            .block(Duration.ofSeconds(60));
+                    Map<String, Object> payload = new LinkedHashMap<>();
+                    payload.put("attachmentId", attachmentId);
+                    payload.put("sessionId", sessionId);
+                    payload.put("chunkNo", chunkNo);
+                    vectorMapper.insertVectorDoc(table, attachmentId + ":" + chunkNo, content,
+                            Json.toStr(payload), toVectorLiteral(vec));
+                    ok[0]++;
+                } catch (Exception e) {
+                    log.warn("附件 chunk 向量化失败: attachmentId={}, chunk={}, err={}",
+                            attachmentId, chunkNo, e.getMessage());
+                } finally {
+                    gate.release();
+                }
+            });
+        }
+        // 虚拟线程并发执行,join 全部(块数有上限,内存无虞)
+        List<Thread> threads = jobs.stream()
+                .map(j -> Thread.ofVirtual().name("att-embed-", 0).start(j))
+                .toList();
+        for (Thread t : threads) {
+            try {
+                t.join();
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                break;
+            }
+        }
+        if (ok[0] == 0) {
+            throw new IllegalStateException("附件全部 chunk 向量化失败,请检查嵌入服务");
+        }
+        return new IndexResult(ok[0], truncated);
+    }
+
+    /**
+     * 按会话检索 top-k 片段(cosine,强制会话过滤)。
+     */
+    public List<String> search(String question, Long attachmentId, Long sessionId, Long caseId) {
+        EmbeddingModelFactory.EmbeddingSpec spec = embeddingModelFactory.resolveDefault();
+        if (spec == null) {
+            return List.of();
+        }
+        double[] vec = spec.model().embed(TextBlock.builder().text(question).build())
+                .block(Duration.ofSeconds(60));
+        String table = vectorTableName(caseId, spec.dimensions());
+        List<Map<String, Object>> rows = vectorMapper.searchInSession(table, toVectorLiteral(vec),
+                String.valueOf(sessionId), props.getScoreThreshold(), props.getTopK());
+        List<String> out = new ArrayList<>(rows.size());
+        for (Map<String, Object> row : rows) {
+            // 只要本附件的片段(多附件各自检索自己的内容,避免互相污染);
+            // payload 是 JSON 文本,解析后比对 attachmentId,不做字符串 contains 匹配
+            Object payload = row.get("payload");
+            if (!payloadBelongsTo(payload, attachmentId)) {
+                continue;
+            }
+            Object content = row.get("content");
+            if (content != null) {
+                out.add(content.toString());
+            }
+        }
+        return out;
+    }
+
+    @SuppressWarnings("unchecked")
+    private static boolean payloadBelongsTo(Object payload, Long attachmentId) {
+        if (payload == null) {
+            return false;
+        }
+        try {
+            Map<String, Object> p = Json.objectMapper().readValue(payload.toString(), Map.class);
+            Object id = p.get("attachmentId");
+            return id != null && String.valueOf(id).equals(String.valueOf(attachmentId));
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    /** 分块:固定窗口 + 重叠;段落边界优先(在窗口末尾附近找换行切齐) */
+    static List<String> chunk(String text, int chunkSize, int chunkOverlap) {
+        List<String> out = new ArrayList<>();
+        int size = Math.max(200, chunkSize);
+        int overlap = Math.min(Math.max(0, chunkOverlap), size / 4);
+        int n = text.length();
+        int pos = 0;
+        while (pos < n) {
+            int end = Math.min(n, pos + size);
+            if (end < n) {
+                int soft = text.lastIndexOf('\n', end);
+                if (soft > pos + size / 2) {
+                    end = soft + 1;
+                }
+            }
+            out.add(text.substring(pos, end));
+            if (end >= n) {
+                break;
+            }
+            pos = end - overlap;
+        }
+        return out;
+    }
+
+    /** 撤销某附件的全部向量行(孤儿清理/删除附件时调用;维度未知时按两种常规维度各试一次) */
+    public void deleteAttachmentVectors(Long attachmentId, Long caseId) {
+        for (int dims : new int[]{1024, 1536}) {
+            try {
+                vectorMapper.deleteByAttachment(vectorTableName(caseId, dims), String.valueOf(attachmentId));
+            } catch (Exception ignore) {
+                // 表不存在是常态(该案件还没建过这个维度的向量表),静默
+            }
+        }
+    }
+
+    /** 向量表名(按案件分表,沿 rag_ws{id}_d{dims} 惯例) */
+    public static String vectorTableName(Long caseId, int dims) {
+        return "chat_att_vec_" + caseId + "_d" + dims;
+    }
+
+    /** double[] → pgvector 字面量 */
+    private static String toVectorLiteral(double[] vector) {
+        StringBuilder sb = new StringBuilder(vector.length * 10).append('[');
+        for (int i = 0; i < vector.length; i++) {
+            if (i > 0) {
+                sb.append(',');
+            }
+            sb.append(vector[i]);
+        }
+        return sb.append(']').toString();
+    }
+}

+ 193 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/attachment/AttachmentSheetScanner.java

@@ -0,0 +1,193 @@
+package com.zsjz.ai.module.agent.attachment;
+
+import cn.hutool.core.io.IoUtil;
+import cn.hutool.core.util.CharsetUtil;
+import com.zsjz.ai.module.aiclean.probe.AiRowReader;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.csv.CSVFormat;
+import org.apache.commons.csv.CSVParser;
+import org.apache.commons.csv.CSVRecord;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.Writer;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 表格附件的<b>一次流式扫描</b>(路径 B,不物化不落库):
+ * 行数 + 表头 + 前 N 样本行。100MB CSV 也只占常数内存 ——
+ * Agent 之后用 {@code execute_python} 直读原文件全量计算,这里只给「文件里有什么」。
+ *
+ * <p>csv 用 commons-csv(与 PreTask 同款,含引号内换行/自定义编码支持,
+ * 编码探测沿用 {@code CharsetUtil.defaultCharset});
+ * xls/xlsx 复用 {@link AiRowReader#forEachRow}(与清洗链路读值一致)。
+ * xlsx 同一回调顺写 sidecar csv —— Agent 侧统一 {@code pd.read_csv},不赌 openpyxl。
+ *
+ * @author cc
+ * @since 2026/9/29
+ */
+@Slf4j
+public final class AttachmentSheetScanner {
+
+    private AttachmentSheetScanner() {
+    }
+
+    /**
+     * 扫描结果:注入消息的全部素材。
+     *
+     * @param rowCount  数据行数(不含表头)
+     * @param headers   表头列名
+     * @param samples   样本行(值列表,按表头序)
+     * @param readError 读取中断原因(非空表示行数为「已读到的下界」)
+     */
+    public record SheetScan(int rowCount, List<String> headers,
+                            List<List<String>> samples, String readError) {
+    }
+
+    /**
+     * 流式扫描 CSV:不缓存全表,只留计数与样本。
+     */
+    public static SheetScan scanCsv(File file, int sampleRows) {
+        Charset charset = detectCharset(file);
+        int rows = 0;
+        List<String> headers = new ArrayList<>();
+        List<List<String>> samples = new ArrayList<>();
+        try (BufferedReader reader = Files.newBufferedReader(file.toPath(), charset);
+             CSVParser parser = CSVFormat.DEFAULT.parse(reader)) {
+            boolean first = true;
+            for (CSVRecord rec : parser) {
+                if (first) {
+                    for (String v : rec) {
+                        headers.add(v == null ? "" : v.trim());
+                    }
+                    first = false;
+                    continue;
+                }
+                rows++;
+                if (samples.size() < sampleRows) {
+                    List<String> row = new ArrayList<>(headers.size());
+                    for (int i = 0; i < headers.size(); i++) {
+                        row.add(i < rec.size() ? rec.get(i) : "");
+                    }
+                    samples.add(row);
+                }
+            }
+            return new SheetScan(rows, headers, samples, null);
+        } catch (Exception e) {
+            log.warn("CSV 附件扫描中断: file={}, err={}", file.getName(), e.getMessage());
+            return new SheetScan(rows, headers, samples, e.getMessage());
+        }
+    }
+
+    /**
+     * 流式扫描 xls/xlsx,并把数据行顺写 sidecar csv(给 Agent 的 pandas 读)。
+     *
+     * <p>{@code headerRow=0} 不跳行:首行回调即表头(写进 sidecar 首行),其后才是数据行。
+     * 全空行被 {@code forEachRow} 跳过,与清洗链路读法一致。
+     */
+    public static SheetScan scanExcel(File file, int sampleRows, File sidecarCsv) {
+        int[] rows = {0};
+        List<String> headers = new ArrayList<>();
+        List<List<String>> samples = new ArrayList<>();
+        String[] error = {null};
+        try (Writer w = Files.newBufferedWriter(sidecarCsv.toPath(), StandardCharsets.UTF_8)) {
+            boolean ok = AiRowReader.forEachRow(file, null, 0, (row, lineNo) -> {
+                if (error[0] != null) {
+                    return;
+                }
+                int width = row.size();
+                try {
+                    if (headers.isEmpty()) {
+                        // 首个有效行 = 表头:进 headers 并写 sidecar 首行,不算数据行
+                        for (int i = 0; i < width; i++) {
+                            headers.add(valueAt(row, i));
+                        }
+                        writeHeaderRow(w, headers);
+                        return;
+                    }
+                    rows[0]++;
+                    if (samples.size() < sampleRows) {
+                        List<String> sample = new ArrayList<>(Math.max(width, headers.size()));
+                        for (int i = 0; i < Math.max(width, headers.size()); i++) {
+                            sample.add(valueAt(row, i));
+                        }
+                        samples.add(sample);
+                    }
+                    writeCsvRow(w, row, headers.size());
+                } catch (IOException e) {
+                    error[0] = e.getMessage();
+                }
+            });
+            if (!ok && error[0] == null) {
+                error[0] = "文件读取失败";
+            }
+        } catch (IOException e) {
+            log.warn("Excel 附件转写失败: file={}, err={}", file.getName(), e.getMessage());
+            error[0] = e.getMessage();
+        }
+        return new SheetScan(rows[0], headers, samples, error[0]);
+    }
+
+    /**
+     * 给 sidecar 表头行(xlsx 转写的首行)。
+     */
+    public static void writeHeaderRow(Writer w, List<String> headers) throws IOException {
+        writeCsvLine(w, headers);
+    }
+
+    private static String valueAt(Map<Integer, String> row, int idx) {
+        String v = row.get(idx);
+        return v == null ? "" : v;
+    }
+
+    /** 行 → csv 物理行(引号转义,与 commons-csv 输出兼容) */
+    private static void writeCsvRow(Writer w, Map<Integer, String> row, int width) throws IOException {
+        List<String> cells = new ArrayList<>(Math.max(width, row.size()));
+        for (int i = 0; i < Math.max(width, row.size()); i++) {
+            cells.add(valueAt(row, i));
+        }
+        writeCsvLine(w, cells);
+    }
+
+    private static void writeCsvLine(Writer w, List<String> cells) throws IOException {
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < cells.size(); i++) {
+            if (i > 0) {
+                sb.append(',');
+            }
+            sb.append(escapeCsv(cells.get(i)));
+        }
+        sb.append('\n');
+        w.write(sb.toString());
+    }
+
+    private static String escapeCsv(String v) {
+        if (v == null) {
+            return "";
+        }
+        if (v.contains(",") || v.contains("\"") || v.contains("\n") || v.contains("\r")) {
+            return '"' + v.replace("\"", "\"\"") + '"';
+        }
+        return v;
+    }
+
+    /**
+     * 编码探测(与 PreTask 同款 hutool {@code CharsetUtil.defaultCharset});
+     * 探测消耗流位置,主流程因此重新打开文件读,两边互不影响。
+     */
+    private static Charset detectCharset(File file) {
+        try (var in = Files.newInputStream(file.toPath())) {
+            Charset detected = CharsetUtil.defaultCharset(in);
+            return detected == null ? StandardCharsets.UTF_8 : detected;
+        } catch (Exception e) {
+            return StandardCharsets.UTF_8;
+        }
+    }
+}

+ 56 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/attachment/AttachmentVectorMapper.java

@@ -0,0 +1,56 @@
+package com.zsjz.ai.module.agent.attachment;
+
+import org.apache.ibatis.annotations.Delete;
+import org.apache.ibatis.annotations.Insert;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 附件 RAG 向量表 Mapper(原生 SQL,动态表名 {@code chat_att_vec_{caseId}_d{dims}})。
+ *
+ * <p>与 {@code RagVectorMapper} 同款形态,但检索<b>强制按会话过滤</b>
+ * (payload 里的 sessionId)—— 同案件其他会话搜不到别人的附件片段(隔离要求)。
+ */
+@Mapper
+public interface AttachmentVectorMapper {
+
+    @Update("CREATE TABLE IF NOT EXISTS public.${tableName} ("
+            + "doc_id VARCHAR(128) PRIMARY KEY, "
+            + "content TEXT, "
+            + "payload JSONB, "
+            + "embedding vector(${dims}))")
+    void createVectorTable(@Param("tableName") String tableName, @Param("dims") int dims);
+
+    @Insert("INSERT INTO public.${tableName} (doc_id, content, payload, embedding) "
+            + "VALUES (#{docId}, #{content}, #{payloadJson}::jsonb, #{embedding}::vector) "
+            + "ON CONFLICT (doc_id) DO NOTHING")
+    void insertVectorDoc(@Param("tableName") String tableName,
+                         @Param("docId") String docId,
+                         @Param("content") String content,
+                         @Param("payloadJson") String payloadJson,
+                         @Param("embedding") String embedding);
+
+    /**
+     * cosine 检索 + 会话隔离过滤(payload->>'sessionId' 必须命中)。
+     */
+    @Select("SELECT content, payload::text AS payload, 1 - (embedding <=> #{embedding}::vector) AS score "
+            + "FROM public.${tableName} "
+            + "WHERE payload->>'sessionId' = #{sessionId} "
+            + "  AND 1 - (embedding <=> #{embedding}::vector) >= #{threshold} "
+            + "ORDER BY score DESC LIMIT #{limit}")
+    List<Map<String, Object>> searchInSession(@Param("tableName") String tableName,
+                                              @Param("embedding") String embedding,
+                                              @Param("sessionId") String sessionId,
+                                              @Param("threshold") double threshold,
+                                              @Param("limit") int limit);
+
+    /** 删除某附件的全部向量行(孤儿清理/撤销) */
+    @Delete("DELETE FROM public.${tableName} WHERE payload->>'attachmentId' = #{attachmentId}")
+    int deleteByAttachment(@Param("tableName") String tableName,
+                           @Param("attachmentId") String attachmentId);
+}

+ 105 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/attachment/ChatAttachment.java

@@ -0,0 +1,105 @@
+package com.zsjz.ai.module.agent.attachment;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 对话附件({@code chat_attachment}):一次输入框上传的文件登记。
+ *
+ * <h3>三路处理</h3>
+ * {@link #kind} = DOCUMENT / TABLE / UNSUPPORTED;DOCUMENT 与 TABLE 再按规模分流:
+ * 小内容走 {@link #inlineText} 直接进消息;大表格不物化,Agent 用
+ * {@code execute_python} 读 {@link #filePath} 全量计算;大文档向量化后按问题检索
+ * ({@code chat_att_vec_{caseId}_d{dims}})。
+ *
+ * <h3>隔离</h3>
+ * user_id / case_id / session_id 三重归属是加载校验的依据 —— 附件只会被
+ * 「上传它的那个用户的那个案件的那个会话」读到(见 ChatAttachmentService#loadForSession)。
+ *
+ * @author cc
+ * @since 2026/9/29
+ */
+@Data
+@TableName("chat_attachment")
+public class ChatAttachment implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+
+    @TableField("user_id")
+    private Long userId;
+
+    @TableField("case_id")
+    private Long caseId;
+
+    @TableField("session_id")
+    private Long sessionId;
+
+    @TableField("file_name")
+    private String fileName;
+
+    @TableField("file_path")
+    private String filePath;
+
+    /** 衍生文件:文档全文 sidecar .txt / xlsx 转 .csv(路径 B 统一给 pandas 读) */
+    @TableField("sidecar_path")
+    private String sidecarPath;
+
+    @TableField("mime")
+    private String mime;
+
+    /** DOCUMENT / TABLE / UNSUPPORTED */
+    @TableField("kind")
+    private String kind;
+
+    @TableField("size_bytes")
+    private Long sizeBytes;
+
+    @TableField("char_count")
+    private Integer charCount;
+
+    @TableField("row_count")
+    private Integer rowCount;
+
+    /** 1=内容已截断(内联截断 / 索引超块数截断) */
+    @TableField("truncated")
+    private Integer truncated;
+
+    /** 路径 A 的内联全文 */
+    @TableField("inline_text")
+    private String inlineText;
+
+    @TableField("index_blocks")
+    private Integer indexBlocks;
+
+    /** NONE / READY / FAILED(路径 C) */
+    @TableField("index_status")
+    private String indexStatus;
+
+    /** READY / FAILED */
+    @TableField("status")
+    private String status;
+
+    /** 发出后回填关联消息 id,孤儿清理依据 */
+    @TableField("link_message_id")
+    private Long linkMessageId;
+
+    @TableField("fail_reason")
+    private String failReason;
+
+    @TableField("create_time")
+    private LocalDateTime createTime;
+
+    @TableField("update_time")
+    private LocalDateTime updateTime;
+}

+ 11 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/attachment/ChatAttachmentMapper.java

@@ -0,0 +1,11 @@
+package com.zsjz.ai.module.agent.attachment;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 对话附件 Mapper(master PG)。
+ */
+@Mapper
+public interface ChatAttachmentMapper extends BaseMapper<ChatAttachment> {
+}

+ 81 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/attachment/ChatAttachmentProperties.java

@@ -0,0 +1,81 @@
+package com.zsjz.ai.module.agent.attachment;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * 对话附件配置({@code zsjz.chat-attachment})。
+ *
+ * <p>三路规模阈值都在这里:inline-max-chars / inline-max-rows 以下是「内联」,
+ * 超了按类型分流到「Python 直读」(表格)或「RAG 检索」(文档)。
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "zsjz.chat-attachment")
+public class ChatAttachmentProperties {
+
+    /** 是否启用附件功能 */
+    private boolean enabled = true;
+
+    /** 文档类大小上限(MB):Tika 解析对象,超过直接拒绝 */
+    private long maxDocMb = 50;
+
+    /** 表格类大小上限(MB):流式扫描对象,可比文档大 */
+    private long maxSheetMb = 200;
+
+    /** 文档内联/RAG 的单次提取字符上限(内联截断线,也是全文提取上限) */
+    private int inlineMaxChars = 30000;
+
+    /** 表格内联行数上限(不含表头),超过走 Python 直读 */
+    private int inlineMaxRows = 200;
+
+    /** 表格样本行数(路径 B 注入消息的预览) */
+    private int sampleRows = 20;
+
+    /** RAG 分块大小(字符) */
+    private int chunkSize = 1000;
+
+    /** RAG 分块重叠(字符) */
+    private int chunkOverlap = 100;
+
+    /** RAG 分块数量上限:超出截断索引并标注(约 50 万字) */
+    private int maxChunks = 500;
+
+    /** RAG 检索片段数 */
+    private int topK = 8;
+
+    /** RAG 检索相似度阈值(cosine score = 1 - 距离) */
+    private double scoreThreshold = 0.3;
+
+    /** RAG 索引并发度(embedding 调用) */
+    private int indexConcurrency = 8;
+
+    /** 接受的后缀白名单(小写;空 = 用默认清单) */
+    private List<String> acceptSuffixes;
+
+    /** 文档后缀(Tika 提取 / RAG) */
+    public static final List<String> DEFAULT_DOC_SUFFIXES = List.of(
+            "txt", "md", "log", "json", "xml", "yaml", "yml", "ini", "sql", "tsv",
+            "pdf", "doc", "docx", "rtf", "odt", "wps", "html", "htm", "eml", "csv");
+
+    /** 表格后缀(流式扫描 / Python 直读;csv 两属,按行数分流) */
+    public static final List<String> DEFAULT_SHEET_SUFFIXES = List.of("csv", "xls", "xlsx");
+
+    /**
+     * 生效的后缀白名单(后缀 → kind 的判据,{@code AttachmentContentRouter} 消费)
+     */
+    public List<String> effectiveSuffixes() {
+        return acceptSuffixes == null || acceptSuffixes.isEmpty()
+                ? mergeDefault()
+                : acceptSuffixes.stream().map(s -> s.toLowerCase().trim()).filter(s -> !s.isEmpty()).toList();
+    }
+
+    private static List<String> mergeDefault() {
+        java.util.LinkedHashSet<String> all = new java.util.LinkedHashSet<>(DEFAULT_SHEET_SUFFIXES);
+        all.addAll(DEFAULT_DOC_SUFFIXES);
+        return List.copyOf(all);
+    }
+}

+ 439 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/attachment/ChatAttachmentService.java

@@ -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);
+    }
+}

+ 24 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/controller/AgentChatController.java

@@ -1,6 +1,9 @@
 package com.zsjz.ai.module.agent.controller;
 
+import cn.dev33.satoken.stp.StpUtil;
 import com.zsjz.ai.common.config.Result;
+import com.zsjz.ai.common.exception.ServerException;
+import com.zsjz.ai.module.agent.attachment.ChatAttachmentService;
 import com.zsjz.ai.module.agent.dto.ChatRequestDTO;
 import com.zsjz.ai.module.agent.dto.CreateChatSessionDTO;
 import com.zsjz.ai.module.agent.dto.SendMessageDTO;
@@ -14,6 +17,7 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.http.MediaType;
 import org.springframework.http.codec.ServerSentEvent;
 import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
 import reactor.core.publisher.Flux;
 
 import java.util.List;
@@ -22,6 +26,7 @@ import java.util.List;
  * 聊天控制器
  *
  * <p>本项目已移除用户体系,会话按 {@code caseId}(工作空间)归属,不再做用户级隔离。
+ * 对话附件另按 userId/caseId/sessionId 三重归属校验(见 {@link ChatAttachmentService})。
  */
 @Slf4j
 @RestController
@@ -31,6 +36,25 @@ public class AgentChatController {
 
     private final AgentChatService agentChatService;
     private final RagSchemaService ragSchemaService;
+    private final ChatAttachmentService chatAttachmentService;
+
+    /**
+     * 上传对话附件(输入框附件功能):校验 → 落盘 → 按类型路由提取(内联/Python 直读/RAG)→ 登记。
+     *
+     * <p>sessionId 可空(草稿会话发送前上传),发送时由 doStream 绑定并校验归属。
+     */
+    @PostMapping(value = "/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+    public ChatAttachmentService.AttachmentVO uploadAttachment(@RequestPart("file") MultipartFile file,
+                                                               @RequestParam Long workspaceId,
+                                                               @RequestParam(required = false) Long sessionId) {
+        Long userId;
+        try {
+            userId = StpUtil.getLoginIdAsLong();
+        } catch (Exception e) {
+            throw new ServerException(401, "未登录");
+        }
+        return chatAttachmentService.upload(file, userId, workspaceId, sessionId == null ? 0L : sessionId);
+    }
 
     /**
      * 创建会话

+ 9 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/dto/ChatRequestDTO.java

@@ -2,6 +2,8 @@ package com.zsjz.ai.module.agent.dto;
 
 import lombok.Data;
 
+import java.util.List;
+
 @Data
 public class ChatRequestDTO {
     private String message;
@@ -12,4 +14,11 @@ public class ChatRequestDTO {
      * 模型配置ID(可选;与当前会话绑定的模型不同时触发动态切换)
      */
     private Long modelId;
+
+    /**
+     * 附件 ID 列表(可选;来自 {@code POST /chat/attachments} 上传返回)。
+     * 发送时按「附件块 + 用户问题」组装 USER 消息交给会话绑定的 Agent;
+     * 附件按 userId/caseId/sessionId 归属校验,越权直接拒绝。
+     */
+    private List<Long> attachmentIds;
 }

+ 42 - 6
ai-server/src/main/java/com/zsjz/ai/module/agent/service/impl/AgentChatServiceImpl.java

@@ -70,6 +70,7 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
     private final AgentModelService agentModelService;
     private final AgentCapabilityService agentCapabilityService;
     private final UsageStore usageStore;
+    private final com.zsjz.ai.module.agent.attachment.ChatAttachmentService chatAttachmentService;
     private static final ObjectMapper MAPPER = new ObjectMapper();
 
     public AgentChatServiceImpl(UsageStore usageStore,
@@ -77,13 +78,15 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
                                 AgentModelService agentModelService,
                                 AgentCapabilityService agentCapabilityService,
                                 AgentMessageMapper messageMapper,
-                                AgentChatSessionMapper chatSessionMapper) {
+                                AgentChatSessionMapper chatSessionMapper,
+                                com.zsjz.ai.module.agent.attachment.ChatAttachmentService chatAttachmentService) {
         this.usageStore = usageStore;
         this.agentService = agentService;
         this.agentModelService = agentModelService;
         this.agentCapabilityService = agentCapabilityService;
         this.messageMapper = messageMapper;
         this.chatSessionMapper = chatSessionMapper;
+        this.chatAttachmentService = chatAttachmentService;
     }
 
     @Override
@@ -317,8 +320,14 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
         }
         AgentChatSession session = requireSession(sessionId);
 
+        // 附件:归属校验 + 注入块(内联全文 / 表格 Python 直读指引 / RAG 片段)。
+        // 草稿期上传的附件(sessionId=0)在此绑定到当前会话;越权/失败直接 400 不进流
+        List<String> attachmentBlocks = chatAttachmentService.buildBlocks(
+                req.getAttachmentIds(), StpUtil.getLoginIdAsLong(), session.getCaseId(), sessionId,
+                req.getMessage());
+
         // 用户提问落库:流式链路此前只落 AI 回复,导致刷新后用户看不到自己的提问
-        persistUserMessage(sessionId, req.getMessage());
+        persistUserMessage(sessionId, req.getMessage(), req.getAttachmentIds());
 
         String runtimeUser = RUNTIME_USER_ID;
         Long workspaceId = session.getCaseId();
@@ -359,9 +368,11 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
                 .build();
 
         // 意图识别由 IntentMiddleware 在 onAgent 钩子内完成(消息增强 + intent 事件),
-        // 这里原文直发,ChatServiceImpl 只负责事件转换
+        // 这里原文直发,ChatServiceImpl 只负责事件转换;
+        // 带附件时组装「附件块 + 用户问题」单条 USER 消息(Claude document block 同款内联)
+        String userText = buildUserText(attachmentBlocks, req.getMessage());
         List<Msg> msgs = List.of(
-                Msg.builder().role(MsgRole.USER).textContent(req.getMessage()).build());
+                Msg.builder().role(MsgRole.USER).textContent(userText).build());
         Flux<AgentEvent> events = agent.streamEvents(msgs, ctx);
 
         long startMs = System.currentTimeMillis();
@@ -534,7 +545,22 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
      * <p>流式链路({@code /chat/stream})原先只落 AI 回复,导致刷新会话后看不到用户自己的提问,
      * 会话转录缺一半。这里在每轮流式开始时落库一次;失败不影响流式输出。
      */
-    private void persistUserMessage(Long sessionId, String text) {
+    /**
+     * 组装最终发给 Agent 的 USER 文本:附件块在前、用户问题在后(无附件即原文)。
+     */
+    private static String buildUserText(List<String> attachmentBlocks, String message) {
+        if (attachmentBlocks == null || attachmentBlocks.isEmpty()) {
+            return message;
+        }
+        StringBuilder sb = new StringBuilder();
+        for (String block : attachmentBlocks) {
+            sb.append(block).append('\n');
+        }
+        sb.append("【用户问题】").append(message);
+        return sb.toString();
+    }
+
+    private void persistUserMessage(Long sessionId, String text, List<Long> attachmentIds) {
         try {
             if (!StringUtils.hasText(text)) {
                 return;
@@ -547,9 +573,19 @@ public class AgentChatServiceImpl extends ServiceImpl<AgentChatSessionMapper, Ag
             userMessage.setMessageType("text");
             userMessage.setStarred(false);
             userMessage.setCreateAt(LocalDateTime.now());
+            // 附件徽标元数据(历史消息渲染用):只存摘要,正文不进业务表
+            AgentChatSession session = chatSessionMapper.selectById(sessionId);
+            List<Map<String, Object>> badges = chatAttachmentService.badgeMeta(attachmentIds,
+                    StpUtil.getLoginIdAsLong(), session == null ? null : session.getCaseId(), sessionId);
+            if (!badges.isEmpty()) {
+                Map<String, Object> meta = new java.util.LinkedHashMap<>();
+                meta.put("attachments", badges);
+                userMessage.setMetadata(com.zsjz.ai.common.utils.Json.toStr(meta));
+            }
             messageMapper.insert(userMessage);
+            // 附件与消息关联(孤儿清理依据)
+            chatAttachmentService.linkToMessage(attachmentIds, userMessage.getId());
 
-            AgentChatSession session = chatSessionMapper.selectById(sessionId);
             int current = (session != null && session.getMessageCount() != null) ? session.getMessageCount() : 0;
             chatSessionMapper.update(
                     null,

+ 20 - 0
ai-server/src/main/resources/application.yaml

@@ -111,6 +111,26 @@ zsjz:
     batch-token-budget: 0
     stale-minutes: 30
 
+  # 对话附件(输入框附件问答,module/agent/attachment)。
+  # 三路:小内容内联进消息;大表格只给文件路径,Agent 用 execute_python 全量计算;
+  # 大文档分块向量化(RAG)按问题检索片段注入 —— 大文件不截断不算错。
+  # 大文档 RAG 依赖 model 表里有活跃的 type=embedding 模型(与 RagSchemaService 同一前提)。
+  # 附件按 userId/caseId/sessionId 三层隔离;24h 未发消息的孤儿附件自动清理。
+  chat-attachment:
+    # 文档大小上限(MB,Tika 解析对象)
+    max-doc-mb: 50
+    # 表格大小上限(MB,流式扫描对象,100MB csv 也没问题)
+    max-sheet-mb: 200
+    # 文档内联/RAG 的提取字符上限(超过截断并标注;也是 sidecar 写入上限)
+    inline-max-chars: 30000
+    # 表格内联行数上限(超过走 Python 直读)
+    inline-max-rows: 200
+    # RAG 分块/重叠/块数上限/检索条数
+    chunk-size: 1000
+    chunk-overlap: 100
+    max-chunks: 500
+    top-k: 8
+
 mybatis-plus:
   # 实体别名包(各业务域实体)。已核实 10 个域的实体类简单名无重复,可安全启用;
   # 若后续新增实体出现重名,MyBatis 会因别名冲突启动失败,此时应删除本配置(XML 全用全限定名)。

+ 76 - 0
ai-server/src/test/java/com/zsjz/ai/module/agent/attachment/AttachmentContentRouterTest.java

@@ -0,0 +1,76 @@
+package com.zsjz.ai.module.agent.attachment;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 类型路由与 RAG 分块的纯函数测试。
+ */
+class AttachmentContentRouterTest {
+
+    @Test
+    @DisplayName("后缀白名单路由:表格 TABLE / 文档 DOCUMENT")
+    void routesBySuffix() {
+        assertEquals(AttachmentContentRouter.Kind.TABLE,
+                AttachmentContentRouter.route("流水.csv", null));
+        assertEquals(AttachmentContentRouter.Kind.TABLE,
+                AttachmentContentRouter.route("报表.XLSX", "application/octet-stream"));
+        assertEquals(AttachmentContentRouter.Kind.DOCUMENT,
+                AttachmentContentRouter.route("合同.pdf", null));
+        assertEquals(AttachmentContentRouter.Kind.DOCUMENT,
+                AttachmentContentRouter.route("说明.txt", null));
+    }
+
+    @Test
+    @DisplayName("图片/音视频/压缩包/加密一律拒绝(无 OCR/ASR)")
+    void rejectsUnsupported() {
+        assertEquals(AttachmentContentRouter.Kind.UNSUPPORTED,
+                AttachmentContentRouter.route("图.png", "image/png"));
+        assertEquals(AttachmentContentRouter.Kind.UNSUPPORTED,
+                AttachmentContentRouter.route("录音.mp3", "audio/mpeg"));
+        assertEquals(AttachmentContentRouter.Kind.UNSUPPORTED,
+                AttachmentContentRouter.route("包.zip", "application/zip"));
+        assertEquals(AttachmentContentRouter.Kind.UNSUPPORTED,
+                AttachmentContentRouter.route("数据.dat", "application/octet-stream"));
+        assertEquals(AttachmentContentRouter.Kind.UNSUPPORTED,
+                AttachmentContentRouter.route("密.docx", "application/vnd.encrypted"));
+    }
+
+    @Test
+    @DisplayName("未知后缀但 MIME 是文本 → 当文档放行")
+    void textMimeFallback() {
+        assertEquals(AttachmentContentRouter.Kind.DOCUMENT,
+                AttachmentContentRouter.route("奇怪扩展名.data", "text/plain"));
+    }
+
+    @Test
+    @DisplayName("RAG 分块:总字符不丢、有重叠、窗口上限生效")
+    void chunkCoversTextWithOverlap() {
+        String text = "abcdefghij".repeat(500); // 5000 字
+        List<String> chunks = AttachmentDocIndexer.chunk(text, 1000, 100);
+        assertTrue(chunks.size() >= 5, "应切成多块: " + chunks.size());
+        int total = chunks.stream().mapToInt(String::length).sum();
+        assertTrue(total >= text.length(), "块总量(含重叠)不能少于原文");
+        for (String c : chunks) {
+            assertTrue(c.length() <= 1000 + 1, "单块不超窗口上限");
+        }
+        // 首尾拼回验证无内容丢失(相邻块因重叠交叠)
+        assertTrue(text.startsWith(chunks.get(0)));
+        assertTrue(text.endsWith(chunks.get(chunks.size() - 1)));
+    }
+
+    @Test
+    @DisplayName("短文本只切一块")
+    void shortTextOneChunk() {
+        List<String> chunks = AttachmentDocIndexer.chunk("短文本", 1000, 100);
+        assertEquals(1, chunks.size());
+        assertEquals("短文本", chunks.getFirst());
+        assertFalse(chunks.isEmpty());
+    }
+}

+ 78 - 0
ai-server/src/test/java/com/zsjz/ai/module/agent/attachment/AttachmentSheetScannerTest.java

@@ -0,0 +1,78 @@
+package com.zsjz.ai.module.agent.attachment;
+
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.FileOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 表格附件流式扫描测试:csv 行数/表头/样本、xlsx 转 sidecar csv。
+ */
+class AttachmentSheetScannerTest {
+
+    @TempDir
+    Path tmp;
+
+    @Test
+    @DisplayName("CSV:行数不含表头、样本截到上限、引号转义可解析")
+    void scansCsv() throws Exception {
+        Path csv = tmp.resolve("a.csv");
+        Files.writeString(csv, "姓名,金额\n张三,100\n李四,\"200,000\"\n王五,300\n", StandardCharsets.UTF_8);
+        AttachmentSheetScanner.SheetScan scan = AttachmentSheetScanner.scanCsv(csv.toFile(), 2);
+        assertNull(scan.readError());
+        assertEquals(3, scan.rowCount(), "行数不含表头");
+        assertEquals(java.util.List.of("姓名", "金额"), scan.headers());
+        assertEquals(2, scan.samples().size(), "样本截到 2 行");
+        assertEquals("李四", scan.samples().get(1).get(0));
+    }
+
+    @Test
+    @DisplayName("CSV:引号内换行不拆行(commons-csv 语义)")
+    void csvQuotedNewline() throws Exception {
+        Path csv = tmp.resolve("b.csv");
+        Files.writeString(csv, "a,b\n\"多行\n值\",1\n", StandardCharsets.UTF_8);
+        AttachmentSheetScanner.SheetScan scan = AttachmentSheetScanner.scanCsv(csv.toFile(), 10);
+        assertNull(scan.readError());
+        assertEquals(1, scan.rowCount());
+    }
+
+    @Test
+    @DisplayName("Excel:首行为表头、数据行计数正确、sidecar csv 可被再次扫描")
+    void scansExcelAndWritesSidecar() throws Exception {
+        Path xlsx = tmp.resolve("t.xlsx");
+        try (XSSFWorkbook wb = new XSSFWorkbook()) {
+            var sheet = wb.createSheet("S");
+            sheet.createRow(0).createCell(0).setCellValue("列A");
+            sheet.getRow(0).createCell(1).setCellValue("列B");
+            for (int i = 1; i <= 5; i++) {
+                var r = sheet.createRow(i);
+                r.createCell(0).setCellValue("v" + i);
+                r.createCell(1).setCellValue(i);
+            }
+            try (FileOutputStream out = new FileOutputStream(xlsx.toFile())) {
+                wb.write(out);
+            }
+        }
+        Path sidecar = tmp.resolve("t.csv");
+        AttachmentSheetScanner.SheetScan scan =
+                AttachmentSheetScanner.scanExcel(xlsx.toFile(), 3, sidecar.toFile());
+        assertNull(scan.readError());
+        assertEquals(5, scan.rowCount(), "表头不算数据行");
+        assertEquals(java.util.List.of("列A", "列B"), scan.headers());
+        assertEquals(3, scan.samples().size());
+        // sidecar 是合法 csv:再扫一遍应回到同样的行数与表头
+        AttachmentSheetScanner.SheetScan again = AttachmentSheetScanner.scanCsv(sidecar.toFile(), 10);
+        assertEquals(5, again.rowCount(), "sidecar 行数应与原表一致");
+        assertEquals(java.util.List.of("列A", "列B"), again.headers());
+        assertTrue(Files.size(sidecar) > 0);
+    }
+}

+ 165 - 0
ai-server/src/test/java/com/zsjz/ai/module/agent/attachment/ChatAttachmentServiceTest.java

@@ -0,0 +1,165 @@
+package com.zsjz.ai.module.agent.attachment;
+
+import com.zsjz.ai.common.exception.ServerException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 附件加载的归属隔离与注入块构造测试(不连库不连模型)。
+ *
+ * <p>隔离是硬要求:跨用户/跨案件/跨会话的附件必须读不到 —— 这些断言就是那道墙的验收。
+ */
+class ChatAttachmentServiceTest {
+
+    static {
+        // MP 的 lambda 列名解析挂在「实体已注册 TableInfo」上(与 AiSmartCleanServiceTest 同款),
+        // 不起 Spring 时手动注册一次,否则 Wrappers.lambdaUpdate() 直接抛
+        com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
+                new org.apache.ibatis.builder.MapperBuilderAssistant(
+                        new com.baomidou.mybatisplus.core.MybatisConfiguration(), ""),
+                ChatAttachment.class);
+    }
+
+    private ChatAttachmentMapper mapper;
+    private AttachmentDocIndexer indexer;
+    private ChatAttachmentService service;
+
+    @BeforeEach
+    void setUp() {
+        mapper = mock(ChatAttachmentMapper.class);
+        indexer = mock(AttachmentDocIndexer.class);
+        service = new ChatAttachmentService(mapper, indexer, new ChatAttachmentProperties());
+    }
+
+    private static ChatAttachment att(long id, long userId, long caseId, long sessionId, String kind) {
+        ChatAttachment a = new ChatAttachment();
+        a.setId(id);
+        a.setUserId(userId);
+        a.setCaseId(caseId);
+        a.setSessionId(sessionId);
+        a.setFileName("f-" + id + ".txt");
+        a.setFilePath("/tmp/f-" + id + ".txt");
+        a.setKind(kind);
+        a.setStatus("READY");
+        a.setRowCount(0);
+        a.setCharCount(0);
+        a.setTruncated(0);
+        a.setIndexBlocks(0);
+        return a;
+    }
+
+    @Test
+    @DisplayName("跨用户 / 跨案件 / 跨会话一律拒绝(400),不泄露任何内容")
+    void rejectsCrossOwnership() {
+        // 跨用户
+        when(mapper.selectById(1L)).thenReturn(att(1L, 999L, 1L, 1L, "DOCUMENT"));
+        assertThrows(ServerException.class,
+                () -> service.buildBlocks(List.of(1L), 1L, 1L, 1L, "问题"));
+        // 跨案件
+        when(mapper.selectById(2L)).thenReturn(att(2L, 1L, 999L, 1L, "DOCUMENT"));
+        assertThrows(ServerException.class,
+                () -> service.buildBlocks(List.of(2L), 1L, 1L, 1L, "问题"));
+        // 跨会话(已绑定到别的会话)
+        when(mapper.selectById(3L)).thenReturn(att(3L, 1L, 1L, 888L, "DOCUMENT"));
+        assertThrows(ServerException.class,
+                () -> service.buildBlocks(List.of(3L), 1L, 1L, 1L, "问题"));
+    }
+
+    @Test
+    @DisplayName("草稿期附件(sessionId=0)发送时绑定到当前会话")
+    void bindsDraftAttachmentToSession() {
+        when(mapper.selectById(10L)).thenReturn(att(10L, 1L, 1L, 0L, "TABLE"));
+        List<String> blocks = service.buildBlocks(List.of(10L), 1L, 1L, 55L, "问题");
+        assertEquals(1, blocks.size());
+        // 绑定动作发生在加载阶段(update 换 sessionId)
+        verify(mapper).update(any(), any());
+    }
+
+    @Test
+    @DisplayName("内联文档块:全文在 <<<ATTACHMENT_START>>> 分隔内,问题原文独立")
+    void inlineDocumentBlock() {
+        ChatAttachment a = att(20L, 1L, 1L, 1L, "DOCUMENT");
+        a.setInlineText("文档全文内容");
+        a.setCharCount(6);
+        when(mapper.selectById(20L)).thenReturn(a);
+        List<String> blocks = service.buildBlocks(List.of(20L), 1L, 1L, 1L, "问题");
+        assertEquals(1, blocks.size());
+        String block = blocks.getFirst();
+        assertTrue(block.contains("<<<ATTACHMENT_START>>>"), block);
+        assertTrue(block.contains("文档全文内容"), block);
+        assertTrue(block.contains("f-20.txt"), block);
+    }
+
+    @Test
+    @DisplayName("大表格块:给出文件路径与全量计算指引(execute_python + pd.read_csv),绝不让模型只看样本")
+    void largeTableBlockGuidesPython() {
+        ChatAttachment a = att(30L, 1L, 1L, 1L, "TABLE");
+        a.setRowCount(100000);
+        a.setFilePath("/data/big.csv");
+        when(mapper.selectById(30L)).thenReturn(a);
+        List<String> blocks = service.buildBlocks(List.of(30L), 1L, 1L, 1L, "总金额多少");
+        String block = blocks.getFirst();
+        assertTrue(block.contains("/data/big.csv"), "必须给出数据文件路径: " + block);
+        assertTrue(block.contains("execute_python"), "必须指引用 Python 全量计算");
+        assertTrue(block.contains("100000"), "应给出总行数");
+    }
+
+    @Test
+    @DisplayName("RAG 文档块:按问题检索片段并带检索结果")
+    void ragDocumentBlock() {
+        ChatAttachment a = att(40L, 1L, 1L, 1L, "DOCUMENT");
+        a.setIndexBlocks(12);
+        a.setCharCount(200000);
+        when(mapper.selectById(40L)).thenReturn(a);
+        when(indexer.search(eq("问题内容"), eq(40L), eq(1L), eq(1L)))
+                .thenReturn(List.of("相关片段甲", "相关片段乙"));
+        List<String> blocks = service.buildBlocks(List.of(40L), 1L, 1L, 1L, "问题内容");
+        String block = blocks.getFirst();
+        assertTrue(block.contains("相关片段甲"), block);
+        assertTrue(block.contains("12 片"), block);
+    }
+
+    @Test
+    @DisplayName("处理失败的附件直接拒绝,不进消息")
+    void failedAttachmentRejected() {
+        ChatAttachment a = att(50L, 1L, 1L, 1L, "DOCUMENT");
+        a.setStatus("FAILED");
+        a.setFailReason("文档文本提取失败");
+        when(mapper.selectById(50L)).thenReturn(a);
+        ServerException e = assertThrows(ServerException.class,
+                () -> service.buildBlocks(List.of(50L), 1L, 1L, 1L, "问题"));
+        assertTrue(e.getMessage().contains("处理失败"));
+    }
+
+    @Test
+    @DisplayName("文件名 sanitize:路径部分被剥离(basename)、非法字符被替换")
+    void sanitizeFileName() {
+        // FileUtil.getName 取 basename,路径穿越成分在第一步就丢了(比替换更安全)
+        assertEquals("passwd", ChatAttachmentService.sanitizeFileName("../../etc/passwd"));
+        assertEquals("b.txt", ChatAttachmentService.sanitizeFileName("a/b.txt"));
+        assertEquals("attachment", ChatAttachmentService.sanitizeFileName(""));
+    }
+
+    @Test
+    @DisplayName("无附件时空列表,不动库")
+    void emptyIdsNoop() {
+        assertTrue(service.buildBlocks(null, 1L, 1L, 1L, "q").isEmpty());
+        assertTrue(service.buildBlocks(List.of(), 1L, 1L, 1L, "q").isEmpty());
+        verify(mapper, never()).selectById(anyLong());
+    }
+}

+ 39 - 0
sql/chat_attachment.sql

@@ -0,0 +1,39 @@
+-- ============================================================================
+-- 对话附件(module/agent/attachment)元数据表
+--
+-- 落在 **master PG**(与 chat/message 管理表同库)。附件问答三路的登记与归属锚点:
+--   A 内联     小文档/小表格 → 提取文本进消息
+--   B Python   大表格 → 流式扫描(行数/表头/样本),Agent 用 execute_python 直读文件
+--   C RAG      大文档 → sidecar 全文 + 分块向量化(chat_att_vec_{caseId}_d{dims})
+--
+-- 隔离:user_id/case_id/session_id 三重归属,loadForSession 校验不过即拒绝;
+-- 向量侧另按会话过滤(payload 里带 sessionId,检索 SQL 强制 WHERE)。
+-- 设计文档:输入框附件问答设计(2026-09)
+-- ============================================================================
+
+create table if not exists chat_attachment
+(
+    id              bigint primary key,
+    user_id         bigint       not null,                 -- 归属用户(加载校验)
+    case_id         bigint       not null,                 -- 归属案件(加载校验)
+    session_id      bigint       not null,                 -- 归属会话(加载校验 + RAG 检索过滤)
+    file_name       varchar(500) not null,                 -- 原始文件名(展示用)
+    file_path       varchar(1000) not null,                -- 原件落盘路径(路径 B 的 execute_python 读它)
+    sidecar_path    varchar(1000),                         -- 衍生文本:文档全文 .txt / xlsx 转 .csv
+    mime            varchar(100),                          -- Tika 探测的 MIME
+    kind            varchar(20)   not null,                -- DOCUMENT / TABLE / UNSUPPORTED
+    size_bytes      bigint       default 0,
+    char_count      integer      default 0,                -- 提取文本字符数(文档)
+    row_count       integer      default 0,                -- 数据行数(表格,不含表头)
+    truncated       integer      default 0,                -- 1=内容已截断(文档内联截断或索引超块数)
+    inline_text     text,                                  -- 路径 A 的内联全文(≤inline-max-chars)
+    index_blocks    integer      default 0,                -- 路径 C 已向量化块数
+    index_status    varchar(20),                           -- 路径 C:NONE / READY / FAILED
+    status          varchar(20)   not null,                -- READY / FAILED
+    link_message_id bigint,                                -- 关联的消息 id(发出后回填,孤儿清理依据)
+    fail_reason     varchar(1000),
+    create_time     timestamp    default current_timestamp,
+    update_time     timestamp
+);
+create index if not exists idx_chat_att_session on chat_attachment (session_id);
+create index if not exists idx_chat_att_status on chat_attachment (status, link_message_id);