2026-06-08 29ccd9919082e0157f57989ae83b303f314bad6b
feat(financial): 新增凭证分录科目明细字段

- 在数据库表 fin_voucher_entry 中添加 subject_detail 字段
- 更新应用开发环境配置中的数据库连接URL
- 在 FinLedgerEntryRecordVo 和 FinLedgerRowVo 中添加 subjectDetail 属性
- 在 FinVoucherEntry 实体类中添加 subjectDetail 字段和注解
- 更新 MyBatis 映射文件以包含 subject_detail 字段映射
- 在 FinLedgerServiceImpl 中设置科目明细数据
- 创建凭证分录科目明细字段前端联调文档
已添加13个文件
已修改12个文件
1882 ■■■■■ 文件已修改
.gitignore 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260608_原料质检入库比例字段前端联调文档.md 178 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260608_知识库RAG向量检索功能前端联调文档.md 626 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/20260608_知识库向量检索功能.sql 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/assistant/KnowledgeChatAgent.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/config/XiaozhiAgentConfig.java 53 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/controller/KnowledgeChatController.java 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/dto/KnowledgeChatRequest.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/service/KnowledgeRagService.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/ai/service/impl/KnowledgeRagServiceImpl.java 274 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/approve/controller/KnowledgeBaseController.java 135 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/approve/dto/KnowledgeBaseVectorVO.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/approve/mapper/KnowledgeBaseVectorMapper.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/approve/pojo/KnowledgeBase.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/approve/pojo/KnowledgeBaseVector.java 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/approve/service/KnowledgeBaseVectorService.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/approve/service/impl/KnowledgeBaseVectorServiceImpl.java 144 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/dto/StorageBlobDTO.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/enums/ApplicationTypeEnum.java 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/basic/utils/FileUtil.java 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/pojo/QualityInspect.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/quality/service/impl/QualityInspectServiceImpl.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/service/impl/StockInRecordServiceImpl.java 4 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/ruoyi/stock/service/impl/StockOutRecordServiceImpl.java 4 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/application.yml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.gitignore
@@ -44,3 +44,7 @@
!*/build/*.java
!*/build/*.html
!*/build/*.xml
######################################################################
# Claude Code
.claude/
doc/20260608_Ô­ÁÏÖʼìÈë¿â±ÈÀý×Ö¶Îǰ¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,178 @@
# åŽŸæ–™è´¨æ£€å¢žåŠ å…¥åº“æ¯”ä¾‹å­—æ®µå‰ç«¯è”è°ƒæ–‡æ¡£
## ä¸€ã€åŠŸèƒ½è¯´æ˜Ž
原料质检增加"入库比例"字段(百分比)。当质检入库时,实际入库数量 = åˆæ ¼æ•°é‡ Ã— å…¥åº“比例 / 100。
**示例**:
- åˆæ ¼æ•°é‡ï¼š100
- å…¥åº“比例:80%
- å®žé™…入库数量:100 Ã— 80 / 100 = 80
## äºŒã€æ•°æ®åº“变更
| å­—段名 | ç±»åž‹ | é»˜è®¤å€¼ | è¯´æ˜Ž |
|--------|------|--------|------|
| stock_in_ratio | DECIMAL(5,2) | 100.00 | å…¥åº“比例(百分比),范围0.00~100.00 |
## ä¸‰ã€æŽ¥å£å˜æ›´
### 3.1 æ–°å¢žè´¨æ£€å•
**接口地址**:`POST /quality/qualityInspect/add`
**请求参数新增**:
```json
{
  "stockInRatio": 80.00
}
```
| å‚数名 | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|--------|------|------|------|
| stockInRatio | BigDecimal | å¦ | å…¥åº“比例(百分比),默认100.00 |
### 3.2 ä¿®æ”¹è´¨æ£€å•
**接口地址**:`POST /quality/qualityInspect/update`
**请求参数新增**:同新增接口
### 3.3 è¯¦æƒ…查询
**接口地址**:`GET /quality/qualityInspect/{id}`
**响应参数新增**:
```json
{
  "data": {
    "id": 1,
    "stockInRatio": 80.00,
    "quantity": 100.00,
    "qualifiedQuantity": 100.00,
    "unqualifiedQuantity": 0.00,
    ...
  }
}
```
### 3.4 åˆ†é¡µæŸ¥è¯¢
**接口地址**:`GET /quality/qualityInspect/listPage`
**响应参数新增**:同详情查询
### 3.5 æäº¤æ£€éªŒï¼ˆå…¥åº“)
**接口地址**:`POST /quality/qualityInspect/submit`
**请求参数**:
```json
{
  "id": 1,
  "stockInRatio": 80.00
}
```
**入库逻辑变更**:
- åŽŸé€»è¾‘ï¼šå…¥åº“æ•°é‡ = åˆæ ¼æ•°é‡
- æ–°é€»è¾‘:入库数量 = åˆæ ¼æ•°é‡ Ã— å…¥åº“比例 / 100
### 3.6 æ‰¹é‡å¿«é€Ÿæ£€éªŒ
**接口地址**:`POST /quality/qualityInspect/batchQuickInspect`
批量快速检验时,入库比例使用检验单自身保存的 `stockInRatio` å€¼ï¼Œå¦‚未设置则默认100%。
## å››ã€å‰ç«¯å­—段配置
### 4.1 è¡¨å•字段(新增/编辑页面)
```vue
<el-form-item label="入库比例(%)" prop="stockInRatio">
  <el-input-number
    v-model="form.stockInRatio"
    :precision="2"
    :min="0"
    :max="100"
    :step="1"
    placeholder="请输入入库比例"
  />
</el-form-item>
```
**字段默认值**:
```javascript
data() {
  return {
    form: {
      stockInRatio: 100.00,  // é»˜è®¤100%
      // ... å…¶ä»–字段
    }
  }
}
```
### 4.2 åˆ—表展示
```vue
<el-table-column label="入库比例" prop="stockInRatio" width="100">
  <template #default="{ row }">
    {{ row.stockInRatio ? row.stockInRatio + '%' : '100%' }}
  </template>
</el-table-column>
```
### 4.3 æ ¡éªŒè§„则
```javascript
rules: {
  stockInRatio: [
    { required: false },
    {
      validator: (rule, value, callback) => {
        if (value !== null && value !== undefined) {
          if (value < 0 || value > 100) {
            callback(new Error('入库比例范围0~100'));
          } else {
            callback();
          }
        } else {
          callback();
        }
      },
      trigger: 'blur'
    }
  ]
}
```
### 4.4 è¯¦æƒ…展示
```vue
<el-descriptions-item label="入库比例">
  {{ detail.stockInRatio ? detail.stockInRatio + '%' : '100%' }}
</el-descriptions-item>
```
## äº”、业务规则
1. **默认值**:入库比例默认为100%,即全部入库
2. **范围限制**:0.00 ~ 100.00(支持两位小数)
3. **入库时机**:质检提交时计算入库数量
4. **计算公式**:`实际入库数量 = åˆæ ¼æ•°é‡ Ã— å…¥åº“比例 / 100`
5. **精度处理**:四舍五入保留2位小数
6. **仅对原料质检生效**:inspectType = 0(原材料检验)
## å…­ã€æ³¨æ„äº‹é¡¹
1. å…¥åº“比例仅影响入库数量,不影响合格数量和不合格数量的统计
2. å·²æäº¤çš„质检单不可修改入库比例
3. å¯¼å‡ºæŠ¥è¡¨æ—¶éœ€å±•示入库比例字段
4. å…¥åº“比例为空或小于等于0时,自动按100%处理
## ä¸ƒã€å­—段映射
| å‰ç«¯å­—段 | åŽç«¯å­—段 | æ•°æ®åº“字段 | ç±»åž‹ |
|----------|----------|------------|------|
| stockInRatio | stockInRatio | stock_in_ratio | BigDecimal(5,2) |
doc/20260608_֪ʶ¿âRAGÏòÁ¿¼ìË÷¹¦ÄÜǰ¶ËÁªµ÷Îĵµ.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,626 @@
# çŸ¥è¯†åº“文件上传与RAG向量检索功能前端联调文档
## ä¸€ã€åŠŸèƒ½æ¦‚è¿°
知识库模块新增文件上传功能,上传的文件会实时进入向量库进行切片处理,配合AI模块实现RAG(检索增强生成)问答功能。
### åŠŸèƒ½æ¨¡å—
1. **知识库文件管理**:使用系统已有的附件管理机制上传文件
2. **向量检索处理**:文件内容自动切片并存入向量库(Pinecone)
3. **知识库问答**:基于上传文件内容进行智能问答
## äºŒã€æ•°æ®åº“变更
### 2.1 æ–°å¢žçŸ¥è¯†åº“文件向量记录表
```sql
-- çŸ¥è¯†åº“文件向量记录表(用于跟踪向量化状态)
CREATE TABLE IF NOT EXISTS knowledge_base_vector (
    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
    knowledge_base_id BIGINT NOT NULL COMMENT '关联知识库ID',
    storage_blob_id BIGINT NOT NULL COMMENT '关联文件blob ID',
    file_name VARCHAR(255) NOT NULL COMMENT '文件名称',
    file_type VARCHAR(50) NOT NULL COMMENT '文件类型(docx/pdf/xlsx/txt等)',
    vector_status TINYINT DEFAULT 0 COMMENT '向量化状态: 0-待处理, 1-处理中, 2-已完成, 3-失败',
    vector_error VARCHAR(500) COMMENT '向量化失败原因',
    chunk_count INT DEFAULT 0 COMMENT '切片数量',
    namespace VARCHAR(100) COMMENT '向量命名空间',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    create_user INT COMMENT '创建人',
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    update_user INT COMMENT '更新人',
    tenant_id BIGINT COMMENT '租户ID',
    dept_id BIGINT COMMENT '部门ID',
    INDEX idx_knowledge_base_id (knowledge_base_id),
    INDEX idx_storage_blob_id (storage_blob_id),
    INDEX idx_vector_status (vector_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库文件向量记录表';
```
### 2.2 ä¿®æ”¹çŸ¥è¯†åº“表
```sql
-- çŸ¥è¯†åº“表增加字段
ALTER TABLE knowledge_base
ADD COLUMN file_count INT DEFAULT 0 COMMENT '文件数量',
ADD COLUMN total_chunk_count INT DEFAULT 0 COMMENT '总切片数量',
ADD COLUMN description VARCHAR(500) COMMENT '知识库描述';
```
## ä¸‰ã€æŽ¥å£è®¾è®¡
### 3.1 æ–‡ä»¶ä¸Šä¼ ï¼ˆä½¿ç”¨ç³»ç»Ÿå·²æœ‰æŽ¥å£ï¼‰
**接口地址**:`POST /common/upload`
**请求方式**:multipart/form-data
**请求参数**:
| å‚数名 | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|--------|------|------|------|
| files | MultipartFile[] | æ˜¯ | ä¸Šä¼ çš„æ–‡ä»¶åˆ—表 |
**响应结果**:
```json
{
  "code": 200,
  "msg": "操作成功",
  "data": [
    {
      "id": 123,
      "name": "操作手册.docx",
      "url": "/profile/upload/20260608/xxx.docx",
      "previewURL": "/common/preview/xxx?token=yyy",
      "downloadURL": "/common/download/xxx?token=yyy",
      "storageAttachmentId": null
    }
  ]
}
```
### 3.2 ä¿å­˜çŸ¥è¯†åº“文件关联
上传完成后,调用附件保存接口关联文件到知识库。
**接口地址**:`POST /storageAttachment/add`
**请求参数**:
```json
{
  "recordType": "knowledge_base",
  "recordId": 10,
  "application": "rag_file",
  "storageBlobDTOs": [
    { "id": 123 },
    { "id": 124 }
  ]
}
```
| å‚数名 | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|--------|------|------|------|
| recordType | String | æ˜¯ | å›ºå®šå€¼ `knowledge_base` |
| recordId | Long | æ˜¯ | çŸ¥è¯†åº“ID |
| application | String | æ˜¯ | å›ºå®šå€¼ `rag_file`,标识RAG文件 |
| storageBlobDTOs | List | æ˜¯ | ä¸Šä¼ è¿”回的文件blob列表 |
**响应结果**:
```json
{
  "code": 200,
  "msg": "操作成功"
}
```
### 3.3 çŸ¥è¯†åº“文件列表
**接口地址**:`GET /storageAttachment/list`
**请求参数**:
| å‚数名 | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|--------|------|------|------|
| recordType | String | æ˜¯ | å›ºå®šå€¼ `knowledge_base` |
| recordId | Long | æ˜¯ | çŸ¥è¯†åº“ID |
| application | String | å¦ | å›ºå®šå€¼ `rag_file` |
**响应结果**:
```json
{
  "code": 200,
  "data": [
    {
      "id": 1,
      "storageBlobId": 123,
      "name": "操作手册.docx",
      "url": "/profile/upload/20260608/xxx.docx",
      "previewURL": "/common/preview/xxx?token=yyy",
      "downloadURL": "/common/download/xxx?token=yyy",
      "createTime": "2026-06-08 10:00:00"
    }
  ]
}
```
### 3.4 çŸ¥è¯†åº“文件删除
**接口地址**:`DELETE /storageAttachment/delete`
**请求参数**:
```json
{
  "ids": [1, 2, 3]
}
```
**响应结果**:
```json
{
  "code": 200,
  "msg": "操作成功"
}
```
### 3.5 æŸ¥è¯¢å‘量化状态(新增接口)
**接口地址**:`GET /knowledgeBase/vector/status/{knowledgeBaseId}`
**响应结果**:
```json
{
  "code": 200,
  "data": [
    {
      "id": 1,
      "storageBlobId": 123,
      "fileName": "操作手册.docx",
      "fileType": "docx",
      "vectorStatus": 2,
      "chunkCount": 15,
      "namespace": "kb-10"
    }
  ]
}
```
### 3.6 é‡æ–°å‘量化文件
**接口地址**:`POST /knowledgeBase/vector/reprocess/{vectorId}`
**响应结果**:
```json
{
  "code": 200,
  "msg": "已重新提交向量化任务"
}
```
### 3.7 çŸ¥è¯†åº“问答接口
**接口地址**:`POST /ai/knowledge/chat`(流式返回)
**请求参数**:
```json
{
  "knowledgeBaseId": 10,
  "memoryId": "session-xxx",
  "question": "如何操作审批流程?"
}
```
| å‚数名 | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|--------|------|------|------|
| knowledgeBaseId | Long | æ˜¯ | çŸ¥è¯†åº“ID |
| memoryId | String | æ˜¯ | ä¼šè¯ID,用于保持上下文 |
| question | String | æ˜¯ | ç”¨æˆ·æé—®å†…容 |
**响应结果**(流式返回 `text/stream;charset=utf-8`):
```
根据知识库内容,审批流程的操作步骤如下:
1. ç™»å½•系统后进入审批管理模块...
```
### 3.8 çŸ¥è¯†åº“问答会话历史
**接口地址**:`GET /ai/knowledge/history/{memoryId}`
**响应结果**:
```json
{
  "code": 200,
  "data": [
    {
      "role": "user",
      "content": "如何操作审批流程?",
      "createTime": "2026-06-08 10:00:00"
    },
    {
      "role": "assistant",
      "content": "根据知识库内容...",
      "createTime": "2026-06-08 10:01:00"
    }
  ]
}
```
## å››ã€æ–‡ä»¶ç±»åž‹æ”¯æŒ
| æ–‡ä»¶ç±»åž‹ | æ‰©å±•名 | è¯´æ˜Ž |
|----------|--------|------|
| Word文档 | .docx | æ”¯æŒæ–‡æœ¬æå– |
| Excel表格 | .xlsx, .xls | æ”¯æŒè¡¨æ ¼å†…容提取 |
| PDF文档 | .pdf | æ”¯æŒPDF文本提取 |
| æ–‡æœ¬æ–‡ä»¶ | .txt, .md, .json, .csv | ç›´æŽ¥è¯»å–内容 |
| ä»£ç æ–‡ä»¶ | .java, .js, .vue, .html, .sql等 | ç›´æŽ¥è¯»å–内容 |
**文件大小限制**:单文件最大10MB(系统默认限制)
## äº”、向量化状态说明
| çŠ¶æ€å€¼ | çŠ¶æ€åç§° | è¯´æ˜Ž |
|--------|----------|------|
| 0 | å¾…处理 | æ–‡ä»¶å·²ä¸Šä¼ ï¼Œç­‰å¾…向量化处理 |
| 1 | å¤„理中 | æ­£åœ¨è¿›è¡Œå‘量切片处理 |
| 2 | å·²å®Œæˆ | å‘量化完成,可进行检索问答 |
| 3 | å¤±è´¥ | å‘量化失败,需重新处理 |
## å…­ã€å‰ç«¯ç»„件设计
### 6.1 æ–‡ä»¶ä¸Šä¼ ç»„件(使用系统已有上传)
```vue
<template>
  <div class="knowledge-file-upload">
    <!-- çŸ¥è¯†åº“选择 -->
    <el-select v-model="selectedKnowledgeBase" placeholder="选择知识库" style="width: 200px">
      <el-option
        v-for="kb in knowledgeBaseList"
        :key="kb.id"
        :label="kb.title"
        :value="kb.id"
      />
    </el-select>
    <!-- ä½¿ç”¨ç³»ç»Ÿå·²æœ‰ä¸Šä¼ ç»„ä»¶ -->
    <el-upload
      :action="uploadUrl"
      :headers="uploadHeaders"
      :on-success="handleUploadSuccess"
      :before-upload="beforeUpload"
      :accept="acceptTypes"
      :limit="10"
      :file-list="fileList"
      multiple
    >
      <el-button type="primary">点击上传</el-button>
      <template #tip>
        <div class="el-upload__tip">
          æ”¯æŒ docx、xlsx、pdf、txt ç­‰æ ¼å¼ï¼Œå•文件不超过10MB
        </div>
      </template>
    </el-upload>
  </div>
</template>
<script setup>
import { ref } from 'vue'
import { getToken } from '@/utils/auth'
import request from '@/utils/request'
const uploadUrl = '/common/upload'
const uploadHeaders = { Authorization: 'Bearer ' + getToken() }
const acceptTypes = '.docx,.xlsx,.xls,.pdf,.txt,.md,.json,.csv'
const uploadedBlobs = ref([])
const beforeUpload = (file) => {
  const maxSize = 10 * 1024 * 1024
  if (file.size > maxSize) {
    ElMessage.error('文件大小不能超过10MB')
    return false
  }
  return true
}
// ä¸Šä¼ æˆåŠŸåŽä¿å­˜é™„ä»¶å…³è”
const handleUploadSuccess = async (response, file) => {
  if (response.code === 200) {
    uploadedBlobs.value.push(...response.data)
    // è°ƒç”¨é™„件保存接口,关联到知识库
    await saveAttachment()
    ElMessage.success('文件上传成功,正在处理向量化...')
    refreshVectorStatus()
  } else {
    ElMessage.error(response.msg)
  }
}
// ä¿å­˜é™„件关联到知识库
const saveAttachment = async () => {
  await request.post('/storageAttachment/add', {
    recordType: 'knowledge_base',
    recordId: selectedKnowledgeBase.value,
    application: 'rag_file',
    storageBlobDTOs: uploadedBlobs.value.map(b => ({ id: b.id }))
  })
  uploadedBlobs.value = []
}
</script>
```
### 6.2 æ–‡ä»¶åˆ—表组件
```vue
<template>
  <el-table :data="fileList" v-loading="loading">
    <el-table-column prop="name" label="文件名" width="200" />
    <el-table-column prop="fileType" label="类型" width="80">
      <template #default="{ row }">
        {{ getFileType(row.name) }}
      </template>
    </el-table-column>
    <el-table-column prop="vectorStatus" label="向量化状态" width="120">
      <template #default="{ row }">
        <el-tag :type="getVectorStatusType(row.vectorStatus)">
          {{ getVectorStatusText(row.vectorStatus) }}
        </el-tag>
      </template>
    </el-table-column>
    <el-table-column prop="chunkCount" label="切片数" width="80" />
    <el-table-column prop="createTime" label="上传时间" width="160" />
    <el-table-column label="操作" width="180">
      <template #default="{ row }">
        <el-button type="primary" size="small" link @click="previewFile(row)">预览</el-button>
        <el-button type="primary" size="small" link @click="downloadFile(row)">下载</el-button>
        <el-button
          v-if="row.vectorStatus === 3"
          type="warning"
          size="small"
          link
          @click="revectorFile(row)"
        >重新处理</el-button>
        <el-button type="danger" size="small" link @click="deleteFile(row)">删除</el-button>
      </template>
    </el-table-column>
  </el-table>
</template>
<script setup>
import request from '@/utils/request'
const getFileType = (name) => {
  return name?.split('.').pop() || ''
}
const getVectorStatusText = (status) => {
  const statusMap = { 0: '待处理', 1: '处理中', 2: '已完成', 3: '失败' }
  return statusMap[status] || '未知'
}
const getVectorStatusType = (status) => {
  const typeMap = { 0: 'info', 1: 'warning', 2: 'success', 3: 'danger' }
  return typeMap[status] || 'info'
}
const previewFile = (row) => {
  window.open(row.previewURL, '_blank')
}
const downloadFile = (row) => {
  window.open(row.downloadURL, '_blank')
}
const deleteFile = async (row) => {
  await request.delete('/storageAttachment/delete', { data: [row.id] })
  ElMessage.success('删除成功')
  refreshFileList()
}
const revectorFile = async (row) => {
  await request.post(`/knowledgeBase/vector/reprocess/${row.vectorId}`)
  ElMessage.success('已重新提交向量化任务')
  refreshVectorStatus()
}
</script>
```
### 6.3 çŸ¥è¯†åº“问答组件
```vue
<template>
  <div class="knowledge-chat">
    <!-- çŸ¥è¯†åº“选择 -->
    <div class="kb-selector">
      <el-select v-model="selectedKnowledgeBase" placeholder="选择知识库">
        <el-option v-for="kb in knowledgeBaseList" :key="kb.id" :label="kb.title" :value="kb.id" />
      </el-select>
    </div>
    <!-- èŠå¤©åŒºåŸŸ -->
    <div class="chat-container">
      <div class="message-list" ref="messageList">
        <div v-for="msg in messages" :key="msg.id" :class="['message', msg.role]">
          <div class="message-content">{{ msg.content }}</div>
        </div>
        <div v-if="streamingContent" class="message assistant">
          <div class="message-content">{{ streamingContent }}</div>
        </div>
      </div>
      <!-- è¾“入区域 -->
      <div class="input-area">
        <el-input
          v-model="question"
          placeholder="输入问题,基于知识库内容回答..."
          @keyup.enter="sendQuestion"
        />
        <el-button type="primary" @click="sendQuestion" :loading="sending">发送</el-button>
      </div>
    </div>
  </div>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { getToken } from '@/utils/auth'
const selectedKnowledgeBase = ref(null)
const question = ref('')
const messages = ref([])
const sending = ref(false)
const streamingContent = ref('')
const memoryId = ref('kb-chat-' + Date.now())
const sendQuestion = async () => {
  if (!question.value.trim() || !selectedKnowledgeBase.value) return
  sending.value = true
  streamingContent.value = ''
  messages.value.push({ id: Date.now(), role: 'user', content: question.value })
  try {
    const response = await fetch('/ai/knowledge/chat', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: 'Bearer ' + getToken()
      },
      body: JSON.stringify({
        knowledgeBaseId: selectedKnowledgeBase.value,
        memoryId: memoryId.value,
        question: question.value
      })
    })
    // æµå¼è¯»å–响应
    const reader = response.body.getReader()
    const decoder = new TextDecoder()
    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      streamingContent.value += decoder.decode(value, { stream: true })
      // æ»šåŠ¨åˆ°åº•éƒ¨
      nextTick(() => {
        const list = document.querySelector('.message-list')
        if (list) list.scrollTop = list.scrollHeight
      })
    }
    messages.value.push({ id: Date.now(), role: 'assistant', content: streamingContent.value })
    streamingContent.value = ''
  } catch (error) {
    ElMessage.error('问答请求失败')
  } finally {
    sending.value = false
    question.value = ''
  }
}
</script>
<style scoped>
.knowledge-chat {
  height: 100%;
  display: flex;
  flex-direction: column;
}
.kb-selector {
  padding: 10px;
  border-bottom: 1px solid #eee;
}
.chat-container {
  flex: 1;
  display: flex;
  flex-direction: column;
}
.message-list {
  flex: 1;
  overflow-y: auto;
  padding: 10px;
}
.message {
  margin-bottom: 10px;
  padding: 10px;
  border-radius: 8px;
}
.message.user {
  background: #e6f7ff;
  text-align: right;
}
.message.assistant {
  background: #f5f5f5;
}
.input-area {
  padding: 10px;
  display: flex;
  gap: 10px;
}
</style>
```
## ä¸ƒã€ä¸šåŠ¡æµç¨‹
### 7.1 æ–‡ä»¶ä¸Šä¼ æµç¨‹
```
前端调用 /common/upload ä¸Šä¼ æ–‡ä»¶
    â†“
获取 StorageBlobVO åˆ—表(包含blobId、预览URL、下载URL)
    â†“
调用 /storageAttachment/add å…³è”文件到知识库
    â†“
后端监听附件保存事件 â†’ æå–文件文本 â†’ åˆ‡ç‰‡ â†’ å‘量化
    â†“
存入 Pinecone å‘量库(命名空间: kb-{knowledgeBaseId})
    â†“
更新 knowledge_base_vector è¡¨çŠ¶æ€ä¸ºå·²å®Œæˆ
```
### 7.2 çŸ¥è¯†åº“问答流程
```
用户提问 â†’ è°ƒç”¨ Embedding æ¨¡åž‹å¯¹é—®é¢˜å‘量化
    â†“
在 Pinecone ä¸­æ£€ç´¢ï¼ˆå‘½åç©ºé—´: kb-{knowledgeBaseId})
    â†“
获取相关切片文本 â†’ ä½œä¸ºä¸Šä¸‹æ–‡ + ç”¨æˆ·é—®é¢˜å‘ç»™ AI æ¨¡åž‹
    â†“
AI æµå¼ç”Ÿæˆå›žç­” â†’ è¿”回前端
```
## å…«ã€æŠ€æœ¯å®žçŽ°è¦ç‚¹
### 8.1 æ–‡æœ¬åˆ‡ç‰‡ç­–ç•¥
- **切片大小**:默认每片 500 å­—符
- **重叠大小**:默认 100 å­—符重叠,保证语义连贯
- **切片元数据**:包含文件ID、知识库ID、切片索引
### 8.2 å‘量命名空间
每个知识库使用独立命名空间:`kb-{knowledgeBaseId}`
### 8.3 é™„件关联参数
| å‚æ•° | å€¼ | è¯´æ˜Ž |
|------|-----|------|
| recordType | `knowledge_base` | ä½¿ç”¨å·²æœ‰æžšä¸¾ |
| application | `rag_file` | æ ‡è¯†ä¸ºRAG文件 |
## ä¹ã€æ³¨æ„äº‹é¡¹
1. æ–‡ä»¶ä¸Šä¼ ä½¿ç”¨ç³»ç»Ÿå·²æœ‰çš„ `/common/upload` å’Œ `/storageAttachment/add` æŽ¥å£
2. åˆ é™¤æ–‡ä»¶æ—¶åŒæ­¥åˆ é™¤å‘量库中的相关切片
3. å¤§æ–‡ä»¶å‘量化可能耗时较长,前端需轮询状态或显示进度
4. çŸ¥è¯†åº“问答依赖向量检索质量,建议优化切片策略
5. ä¸åŒçŸ¥è¯†åº“使用不同命名空间,避免数据混淆
## åã€é”™è¯¯ç è¯´æ˜Ž
| é”™è¯¯ç  | è¯´æ˜Ž |
|--------|------|
| 40001 | æ–‡ä»¶ç±»åž‹ä¸æ”¯æŒ |
| 40002 | æ–‡ä»¶å¤§å°è¶…出限制 |
| 40003 | çŸ¥è¯†åº“不存在 |
| 50001 | æ–‡ä»¶ä¸Šä¼ å¤±è´¥ |
| 50002 | æ–‡ä»¶å†…容提取失败 |
| 50003 | å‘量化处理失败 |
| 50004 | å‘量检索失败 |
doc/20260608_֪ʶ¿âÏòÁ¿¼ìË÷¹¦ÄÜ.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,34 @@
-- 20260608_知识库向量检索功能.sql
-- çŸ¥è¯†åº“RAG向量检索功能数据库变更
-- åˆ›å»ºçŸ¥è¯†åº“文件向量记录表(用于跟踪向量化状态)
CREATE TABLE IF NOT EXISTS knowledge_base_vector (
    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
    knowledge_base_id BIGINT NOT NULL COMMENT '关联知识库ID',
    storage_blob_id BIGINT NOT NULL COMMENT '关联文件blob ID',
    file_name VARCHAR(255) NOT NULL COMMENT '文件名称',
    file_type VARCHAR(50) NOT NULL COMMENT '文件类型(docx/pdf/xlsx/txt等)',
    vector_status TINYINT DEFAULT 0 COMMENT '向量化状态: 0-待处理, 1-处理中, 2-已完成, 3-失败',
    vector_error VARCHAR(500) COMMENT '向量化失败原因',
    chunk_count INT DEFAULT 0 COMMENT '切片数量',
    namespace VARCHAR(100) COMMENT '向量命名空间',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    create_user INT COMMENT '创建人',
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    update_user INT COMMENT '更新人',
    tenant_id BIGINT COMMENT '租户ID',
    dept_id BIGINT COMMENT '部门ID',
    INDEX idx_knowledge_base_id (knowledge_base_id),
    INDEX idx_storage_blob_id (storage_blob_id),
    INDEX idx_vector_status (vector_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库文件向量记录表';
-- çŸ¥è¯†åº“表增加字段
ALTER TABLE knowledge_base
ADD COLUMN file_count INT DEFAULT 0 COMMENT '文件数量',
ADD COLUMN total_chunk_count INT DEFAULT 0 COMMENT '总切片数量',
ADD COLUMN description VARCHAR(500) COMMENT '知识库描述';
-- æ³¨æ„ï¼šé™„件关联使用系统已有的 storage_attachment è¡¨
-- recordType: knowledge_base(已在 RecordTypeEnum ä¸­å®šä¹‰ï¼‰
-- application: rag_file(前端传参时使用)
src/main/java/com/ruoyi/ai/assistant/KnowledgeChatAgent.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
package com.ruoyi.ai.assistant;
import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.spring.AiService;
import reactor.core.publisher.Flux;
import static dev.langchain4j.service.spring.AiServiceWiringMode.EXPLICIT;
/**
 * çŸ¥è¯†åº“问答Agent
 * åŸºäºŽRAG检索增强生成
 */
@AiService(
        wiringMode = EXPLICIT,
        streamingChatModel = "qwenStreamingChatModel",
        chatMemoryProvider = "chatMemoryProvider"
)
public interface KnowledgeChatAgent {
    @SystemMessage("""
            ä½ æ˜¯ä¼ä¸šçŸ¥è¯†åº“问答助手。
            ä½ éœ€è¦åŸºäºŽæä¾›çš„知识库内容回答用户问题。
            éµå¾ªä»¥ä¸‹è§„则:
            1. ä¸¥æ ¼åŸºäºŽçŸ¥è¯†åº“内容回答,不要编造信息
            2. å¦‚果知识库中没有相关信息,明确告知用户
            3. å›žç­”要准确、简洁、有条理
            4. å¦‚果内容较多,使用分点列表形式
            5. å¼•用来源时注明"根据知识库内容"
            """)
    Flux<String> chat(@MemoryId String memoryId, @UserMessage String userMessage);
}
src/main/java/com/ruoyi/ai/config/XiaozhiAgentConfig.java
@@ -12,6 +12,9 @@
import org.springframework.context.annotation.Configuration;
/**
 * AI Agent é…ç½®ç±»
 * çŸ¥è¯†åº“检索使用数据库管理的向量数据,通过 KnowledgeBaseVector è¡¨ç®¡ç†æ–‡ä»¶å‘量记录
 *
 * @author :yys
 * @date : 2025/5/2 20:01
 */
@@ -26,15 +29,6 @@
    @Autowired
    private EmbeddingModel embeddingModel;
//    @Value("${knowledge.one}")
//    private String one;
//
//    @Value("${knowledge.two}")
//    private String two;
//
//    @Value("${knowledge.three}")
//    private String three;
    @Bean
    ChatMemoryProvider chatMemoryProviderXiaozhi() {
        return memoryId -> MessageWindowChatMemory.builder()
@@ -44,48 +38,19 @@
                .build();
    }
//    @Bean
//    ContentRetriever contentRetrieverXiaozhi() {
//        //使用FileSystemDocumentLoader读取指定目录下的知识库文档
//        //并使用默认的文档解析器对文档进行解析
//        Document document1 = FileSystemDocumentLoader.loadDocument(one);
////        Document document2 = FileSystemDocumentLoader.loadDocument(two);
////        Document document3 = FileSystemDocumentLoader.loadDocument(three);
////        List<Document> documents = Arrays.asList(document1, document2, document3);
//
//        List<Document> documents = Collections.singletonList(document1);
////         2. å°†æ•°æ®åº“数据转为LangChain4j的Document对象
////        List<Document> documents = new ArrayList<>();
//
//        //使用内存向量存储
//        InMemoryEmbeddingStore<TextSegment> inMemoryEmbeddingStore = new InMemoryEmbeddingStore<>();
//        //使用默认的文档分割器
//        EmbeddingStoreIngestor.builder()
//                .embeddingModel(embeddingModel)
//                .embeddingStore(inMemoryEmbeddingStore)
//                .build()
//                .ingest(documents);
//        //从嵌入存储(EmbeddingStore)里检索和查询内容相关的信息
//        return EmbeddingStoreContentRetriever.builder()
//                .embeddingModel(embeddingModel)
//                .embeddingStore(inMemoryEmbeddingStore)
//                .build();
//    }
    /**
     * çŸ¥è¯†åº“内容检索器
     * ä»Žå‘量数据库(Pinecone)检索已向量化的知识库内容
     * çŸ¥è¯†åº“文件通过 KnowledgeBaseVector è¡¨ç®¡ç†ï¼Œç”± KnowledgeRagService å¤„理向量化
     */
    @Bean
    ContentRetriever contentRetrieverXiaozhiPincone() {
        // åˆ›å»ºä¸€ä¸ª EmbeddingStoreContentRetriever å¯¹è±¡ï¼Œç”¨äºŽä»ŽåµŒå…¥å­˜å‚¨ä¸­æ£€ç´¢å†…容
    ContentRetriever contentRetrieverXiaozhi() {
        return EmbeddingStoreContentRetriever
                .builder()
                // è®¾ç½®ç”¨äºŽç”ŸæˆåµŒå…¥å‘量的嵌入模型
                .embeddingModel(embeddingModel)
                // æŒ‡å®šè¦ä½¿ç”¨çš„嵌入存储
                .embeddingStore(embeddingStore)
                // è®¾ç½®æœ€å¤§æ£€ç´¢ç»“果数量,这里表示最多返回 1 æ¡åŒ¹é…ç»“æžœ
                .maxResults(1)
                // è®¾ç½®æœ€å°å¾—分阈值,只有得分大于等于 0.8 çš„结果才会被返回
                .minScore(0.8)
                // æž„建最终的 EmbeddingStoreContentRetriever å®žä¾‹
                .build();
    }
}
src/main/java/com/ruoyi/ai/controller/KnowledgeChatController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,91 @@
package com.ruoyi.ai.controller;
import com.ruoyi.ai.assistant.KnowledgeChatAgent;
import com.ruoyi.ai.dto.KnowledgeChatRequest;
import com.ruoyi.ai.service.KnowledgeRagService;
import com.ruoyi.approve.pojo.KnowledgeBase;
import com.ruoyi.approve.service.KnowledgeBaseService;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.security.LoginUser;
import com.ruoyi.framework.web.domain.AjaxResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import java.util.List;
/**
 * çŸ¥è¯†åº“问答Controller
 */
@Slf4j
@RestController
@RequestMapping("/ai/knowledge")
@RequiredArgsConstructor
@Tag(name = "知识库问答")
public class KnowledgeChatController {
    private final KnowledgeChatAgent knowledgeChatAgent;
    private final KnowledgeRagService knowledgeRagService;
    private final KnowledgeBaseService knowledgeBaseService;
    /**
     * çŸ¥è¯†åº“问答(流式返回)
     */
    @PostMapping(value = "/chat", produces = "text/stream;charset=utf-8")
    @Operation(summary = "知识库问答")
    public Flux<String> chat(@RequestBody KnowledgeChatRequest request) {
        if (request.getKnowledgeBaseId() == null) {
            return Flux.just("知识库ID不能为空");
        }
        if (!StringUtils.hasText(request.getMemoryId())) {
            return Flux.just("会话ID不能为空");
        }
        if (!StringUtils.hasText(request.getQuestion())) {
            return Flux.just("问题不能为空");
        }
        // æ£€æŸ¥çŸ¥è¯†åº“是否存在
        KnowledgeBase knowledgeBase = knowledgeBaseService.getById(request.getKnowledgeBaseId());
        if (knowledgeBase == null) {
            return Flux.just("知识库不存在");
        }
        // æž„建命名空间
        String namespace = "kb-" + request.getKnowledgeBaseId();
        // æ£€ç´¢ç›¸å…³å†…容
        List<String> relevantContents = knowledgeRagService.searchRelevantContent(
                namespace, request.getQuestion(), 5);
        if (relevantContents.isEmpty()) {
            return Flux.just("知识库中未找到相关内容,请先上传相关文档。");
        }
        // æž„建上下文
        StringBuilder contextBuilder = new StringBuilder();
        contextBuilder.append("以下是从知识库中检索到的相关内容:\n\n");
        for (int i = 0; i < relevantContents.size(); i++) {
            contextBuilder.append("【内容").append(i + 1).append("】\n");
            contextBuilder.append(relevantContents.get(i)).append("\n\n");
        }
        contextBuilder.append("---\n");
        contextBuilder.append("请基于以上知识库内容回答用户问题:\n");
        contextBuilder.append(request.getQuestion());
        // è°ƒç”¨AI生成回答
        return knowledgeChatAgent.chat(request.getMemoryId(), contextBuilder.toString());
    }
    /**
     * çŸ¥è¯†åº“列表(用于选择知识库)
     */
    @GetMapping("/list")
    @Operation(summary = "知识库列表")
    public AjaxResult listKnowledgeBases() {
        List<KnowledgeBase> list = knowledgeBaseService.list();
        return AjaxResult.success(list);
    }
}
src/main/java/com/ruoyi/ai/dto/KnowledgeChatRequest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
package com.ruoyi.ai.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
 * çŸ¥è¯†åº“问答请求
 */
@Data
@Schema(description = "知识库问答请求")
public class KnowledgeChatRequest {
    @Schema(description = "知识库ID", required = true)
    private Long knowledgeBaseId;
    @Schema(description = "会话ID,用于保持上下文", required = true)
    private String memoryId;
    @Schema(description = "用户提问内容", required = true)
    private String question;
}
src/main/java/com/ruoyi/ai/service/KnowledgeRagService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,34 @@
package com.ruoyi.ai.service;
import java.util.List;
/**
 * çŸ¥è¯†åº“RAG服务
 * è´Ÿè´£æ–‡ä»¶å‘量化处理和检索
 */
public interface KnowledgeRagService {
    /**
     * å¼‚步处理向量化
     */
    void processVectorAsync(Long vectorId);
    /**
     * åŒæ­¥å¤„理向量化
     */
    void processVector(Long vectorId);
    /**
     * æ£€ç´¢ç›¸å…³å†…容
     * @param namespace å‘½åç©ºé—´
     * @param query æŸ¥è¯¢æ–‡æœ¬
     * @param maxResults æœ€å¤§ç»“果数
     * @return ç›¸å…³å†…容列表
     */
    List<String> searchRelevantContent(String namespace, String query, int maxResults);
    /**
     * åˆ é™¤æŒ‡å®šæ–‡ä»¶çš„向量数据
     */
    void deleteEmbeddings(String namespace, Long storageBlobId);
}
src/main/java/com/ruoyi/ai/service/impl/KnowledgeRagServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,274 @@
package com.ruoyi.ai.service.impl;
import com.ruoyi.ai.service.AiFileTextExtractor;
import com.ruoyi.ai.service.KnowledgeRagService;
import com.ruoyi.approve.pojo.KnowledgeBaseVector;
import com.ruoyi.approve.service.KnowledgeBaseVectorService;
import com.ruoyi.basic.pojo.StorageBlob;
import com.ruoyi.basic.service.StorageBlobService;
import com.ruoyi.common.config.FileProperties;
import dev.langchain4j.data.embedding.Embedding;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.store.embedding.EmbeddingMatch;
import dev.langchain4j.store.embedding.EmbeddingSearchRequest;
import dev.langchain4j.store.embedding.EmbeddingSearchResult;
import dev.langchain4j.store.embedding.EmbeddingStore;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.io.File;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
 * çŸ¥è¯†åº“RAG服务实现
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class KnowledgeRagServiceImpl implements KnowledgeRagService {
    private final KnowledgeBaseVectorService knowledgeBaseVectorService;
    private final StorageBlobService storageBlobService;
    private final AiFileTextExtractor aiFileTextExtractor;
    private final EmbeddingModel embeddingModel;
    private final EmbeddingStore<TextSegment> embeddingStore;
    private final FileProperties fileProperties;
    private static final int CHUNK_SIZE = 500;
    private static final int CHUNK_OVERLAP = 100;
    @Override
    @Async
    public void processVectorAsync(Long vectorId) {
        processVector(vectorId);
    }
    @Override
    public void processVector(Long vectorId) {
        KnowledgeBaseVector vector = knowledgeBaseVectorService.getById(vectorId);
        if (vector == null) {
            log.error("向量记录不存在: {}", vectorId);
            return;
        }
        try {
            // æ›´æ–°çŠ¶æ€ä¸ºå¤„ç†ä¸­
            knowledgeBaseVectorService.updateVectorStatus(vectorId,
                    KnowledgeBaseVector.STATUS_PROCESSING, null, null);
            // èŽ·å–æ–‡ä»¶å†…å®¹
            StorageBlob blob = storageBlobService.getById(vector.getStorageBlobId());
            if (blob == null) {
                throw new RuntimeException("文件不存在: " + vector.getStorageBlobId());
            }
            File file = getFile(blob);
            // ç›´æŽ¥è¯»å–文件内容,不使用 MultipartFile åŒ…装
            String content = extractFileContent(file, vector.getFileName(), blob.getContentType());
            if (content == null || content.trim().isEmpty()) {
                throw new RuntimeException("文件内容为空");
            }
            // æ–‡æœ¬åˆ‡ç‰‡
            List<TextSegment> chunks = splitText(content, vector);
            // æ‰¹é‡ç”ŸæˆåµŒå…¥å‘量并存储
            int chunkCount = 0;
            for (TextSegment chunk : chunks) {
                Embedding embedding = embeddingModel.embed(chunk).content();
                embeddingStore.add(embedding, chunk);
                chunkCount++;
            }
            // æ›´æ–°çŠ¶æ€ä¸ºå®Œæˆ
            knowledgeBaseVectorService.updateVectorStatus(vectorId,
                    KnowledgeBaseVector.STATUS_COMPLETED, chunkCount, null);
            log.info("向量化处理完成: vectorId={}, chunkCount={}", vectorId, chunkCount);
        } catch (Exception e) {
            log.error("向量化处理失败: vectorId={}", vectorId, e);
            knowledgeBaseVectorService.updateVectorStatus(vectorId,
                    KnowledgeBaseVector.STATUS_FAILED, null, e.getMessage());
        }
    }
    @Override
    public List<String> searchRelevantContent(String namespace, String query, int maxResults) {
        try {
            // ç”ŸæˆæŸ¥è¯¢å‘量
            Embedding queryEmbedding = embeddingModel.embed(query).content();
            // æž„建搜索请求,使用元数据过滤
            EmbeddingSearchRequest searchRequest = EmbeddingSearchRequest.builder()
                    .queryEmbedding(queryEmbedding)
                    .maxResults(maxResults)
                    .minScore(0.7)
                    .build();
            EmbeddingSearchResult<TextSegment> searchResult = embeddingStore.search(searchRequest);
            return searchResult.matches().stream()
                    .map(match -> match.embedded().text())
                    .collect(Collectors.toList());
        } catch (Exception e) {
            log.error("向量检索失败: namespace={}", namespace, e);
            return new ArrayList<>();
        }
    }
    @Override
    public void deleteEmbeddings(String namespace, Long storageBlobId) {
        // Pinecone æŒ‰å‘½åç©ºé—´åˆ é™¤éœ€è¦ç‰¹å®šå®žçް
        // å½“前实现:通过 metadata è¿‡æ»¤åˆ é™¤
        log.info("删除向量数据: namespace={}, storageBlobId={}", namespace, storageBlobId);
        // æ³¨æ„ï¼šPinecone çš„删除操作需要在 EmbeddingStore å±‚实现
        // å½“前使用 PineconeEmbeddingStore,可能需要调用 Pinecone å®¢æˆ·ç«¯ç›´æŽ¥åˆ é™¤
    }
    private File getFile(StorageBlob blob) {
        String path = blob.getPath();
        if (path != null && !path.isEmpty()) {
            return new File(new File(fileProperties.getPath(), path), blob.getUidFilename());
        }
        return new File(fileProperties.getPath(), blob.getUidFilename());
    }
    /**
     * æå–文件内容
     */
    private String extractFileContent(File file, String fileName, String contentType) throws Exception {
        String ext = getFileExtension(fileName);
        // æ ¹æ®æ–‡ä»¶ç±»åž‹æå–内容
        if (isPlainText(ext)) {
            return Files.readString(file.toPath());
        }
        if ("docx".equals(ext)) {
            return extractDocx(file);
        }
        if ("xlsx".equals(ext)) {
            return extractXlsx(file);
        }
        if ("xls".equals(ext)) {
            return extractXls(file);
        }
        // é»˜è®¤å°è¯•读取文本
        return Files.readString(file.toPath());
    }
    private String getFileExtension(String fileName) {
        if (fileName == null || !fileName.contains(".")) {
            return "";
        }
        return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
    }
    private boolean isPlainText(String ext) {
        return "txt".equals(ext) || "md".equals(ext) || "json".equals(ext)
                || "csv".equals(ext) || "xml".equals(ext) || "yaml".equals(ext)
                || "yml".equals(ext);
    }
    private String extractDocx(File file) throws Exception {
        try (var doc = new org.apache.poi.xwpf.usermodel.XWPFDocument(new java.io.FileInputStream(file));
             var extractor = new org.apache.poi.xwpf.extractor.XWPFWordExtractor(doc)) {
            return extractor.getText();
        }
    }
    private String extractXlsx(File file) throws Exception {
        try (var workbook = new org.apache.poi.xssf.usermodel.XSSFWorkbook(file)) {
            return extractWorkbook(workbook);
        }
    }
    private String extractXls(File file) throws Exception {
        try (var workbook = new org.apache.poi.hssf.usermodel.HSSFWorkbook(new java.io.FileInputStream(file))) {
            return extractWorkbook(workbook);
        }
    }
    private String extractWorkbook(org.apache.poi.ss.usermodel.Workbook workbook) {
        StringBuilder text = new StringBuilder();
        var formatter = new org.apache.poi.ss.usermodel.DataFormatter();
        for (int i = 0; i < workbook.getNumberOfSheets(); i++) {
            var sheet = workbook.getSheetAt(i);
            text.append("Sheet: ").append(sheet.getSheetName()).append("\n");
            for (var row : sheet) {
                for (var cell : row) {
                    text.append(formatter.formatCellValue(cell)).append("\t");
                }
                text.append("\n");
            }
        }
        return text.toString();
    }
    /**
     * æ–‡æœ¬åˆ‡ç‰‡
     */
    private List<TextSegment> splitText(String content, KnowledgeBaseVector vector) {
        List<TextSegment> chunks = new ArrayList<>();
        if (content.length() <= CHUNK_SIZE) {
            Map<String, Object> metadata = buildMetadata(vector);
            chunks.add(TextSegment.from(content, new dev.langchain4j.data.document.Metadata(metadata)));
            return chunks;
        }
        int start = 0;
        int chunkIndex = 0;
        while (start < content.length()) {
            int end = Math.min(start + CHUNK_SIZE, content.length());
            // å°è¯•在句子边界切分
            if (end < content.length()) {
                int lastPeriod = content.lastIndexOf('。', end);
                int lastNewline = content.lastIndexOf('\n', end);
                int boundary = Math.max(lastPeriod, lastNewline);
                if (boundary > start + CHUNK_SIZE / 2) {
                    end = boundary + 1;
                }
            }
            String chunkText = content.substring(start, end).trim();
            if (!chunkText.isEmpty()) {
                Map<String, Object> metadata = buildMetadata(vector);
                metadata.put("chunkIndex", chunkIndex);
                chunks.add(TextSegment.from(chunkText, new dev.langchain4j.data.document.Metadata(metadata)));
                chunkIndex++;
            }
            start = end - CHUNK_OVERLAP;
            if (start < 0) start = 0;
            if (start >= content.length() - CHUNK_OVERLAP) break;
        }
        return chunks;
    }
    private Map<String, Object> buildMetadata(KnowledgeBaseVector vector) {
        Map<String, Object> metadata = new HashMap<>();
        metadata.put("knowledgeBaseId", vector.getKnowledgeBaseId());
        metadata.put("storageBlobId", vector.getStorageBlobId());
        metadata.put("fileName", vector.getFileName());
        metadata.put("namespace", vector.getNamespace());
        return metadata;
    }
}
src/main/java/com/ruoyi/approve/controller/KnowledgeBaseController.java
@@ -2,16 +2,25 @@
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.approve.dto.KnowledgeBaseVectorVO;
import com.ruoyi.approve.pojo.KnowledgeBase;
import com.ruoyi.approve.pojo.KnowledgeBaseVector;
import com.ruoyi.approve.service.KnowledgeBaseService;
import com.ruoyi.approve.service.KnowledgeBaseVectorService;
import com.ruoyi.basic.dto.StorageAttachmentDTO;
import com.ruoyi.basic.dto.StorageBlobDTO;
import com.ruoyi.basic.pojo.StorageAttachment;
import com.ruoyi.basic.service.StorageAttachmentService;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.framework.web.domain.AjaxResult;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
@RestController
@@ -20,40 +29,41 @@
@Tag(name = "知识库管理")
public class KnowledgeBaseController {
    private KnowledgeBaseService knowledgeBaseService;
    private KnowledgeBaseVectorService knowledgeBaseVectorService;
    private StorageAttachmentService storageAttachmentService;
    /**、
    /**
     * èŽ·å–åˆ—è¡¨
     * @return
     */
    @GetMapping("/getList")
    public AjaxResult getList(@RequestParam(defaultValue = "1") long current,
                              @RequestParam(defaultValue = "10") long size, KnowledgeBase knowledgeBase) {
        Page page = new Page(current, size);
        return AjaxResult.success(knowledgeBaseService.listpage(page,knowledgeBase));
        return AjaxResult.success(knowledgeBaseService.listpage(page, knowledgeBase));
    }
    /**、
    /**
     * å¢žæ·»
     * @return
     */
    @PostMapping("/add")
    public AjaxResult add(@RequestBody KnowledgeBase knowledgeBase){
    public AjaxResult add(@RequestBody KnowledgeBase knowledgeBase) {
        return AjaxResult.success(knowledgeBaseService.save(knowledgeBase));
    }
    /**
     * æ›´æ–°
     * @return
     */
    @PostMapping("/update")
    public AjaxResult update(@RequestBody KnowledgeBase knowledgeBase){
    public AjaxResult update(@RequestBody KnowledgeBase knowledgeBase) {
        return AjaxResult.success(knowledgeBaseService.updateById(knowledgeBase));
    }
    /**
     * åˆ é™¤
     * @return
     */
    @DeleteMapping("/delete")
    public AjaxResult delete(@RequestBody List<Long> ids){
        if(CollectionUtils.isEmpty(ids)) return AjaxResult.error("请传入要删除的ID");
    public AjaxResult delete(@RequestBody List<Long> ids) {
        if (CollectionUtils.isEmpty(ids)) return AjaxResult.error("请传入要删除的ID");
        return AjaxResult.success(knowledgeBaseService.removeByIds(ids));
    }
@@ -65,4 +75,103 @@
        util.exportExcel(response, accountExpenses, "知识库管理导出");
    }
}
    /**
     * æŸ¥è¯¢çŸ¥è¯†åº“文件向量化状态
     */
    @GetMapping("/vector/status/{knowledgeBaseId}")
    @Operation(summary = "查询知识库文件向量化状态")
    public AjaxResult getVectorStatus(@PathVariable Long knowledgeBaseId) {
        List<KnowledgeBaseVectorVO> list = knowledgeBaseVectorService.getVectorStatusByKnowledgeBaseId(knowledgeBaseId);
        return AjaxResult.success(list);
    }
    /**
     * é‡æ–°å‘量化文件
     */
    @PostMapping("/vector/reprocess/{vectorId}")
    @Operation(summary = "重新向量化文件")
    public AjaxResult reprocessVector(@PathVariable Long vectorId) {
        knowledgeBaseVectorService.reprocessVector(vectorId);
        return AjaxResult.success("已重新提交向量化任务");
    }
    /**
     * ä¿å­˜çŸ¥è¯†åº“文件关联(文件上传后调用)
     * ä¸Šä¼ æµç¨‹ï¼š
     * 1. å…ˆè°ƒç”¨ /common/upload ä¸Šä¼ æ–‡ä»¶ï¼ŒèŽ·å– storageBlobDTOs
     * 2. å†è°ƒç”¨æ­¤æŽ¥å£å…³è”文件到知识库并触发向量化
     */
    @PostMapping("/file/save")
    @Operation(summary = "保存知识库文件关联")
    public AjaxResult saveKnowledgeBaseFiles(@RequestBody KnowledgeBaseFileDTO dto) {
        if (dto.getKnowledgeBaseId() == null) {
            return AjaxResult.error("知识库ID不能为空");
        }
        if (CollectionUtils.isEmpty(dto.getStorageBlobIds())) {
            return AjaxResult.error("文件ID不能为空");
        }
        // ä¿å­˜é™„件关联
        StorageAttachmentDTO attachmentDTO = new StorageAttachmentDTO();
        attachmentDTO.setRecordType("knowledge_base");
        attachmentDTO.setRecordId(dto.getKnowledgeBaseId());
        attachmentDTO.setApplication("rag_file");
        List<StorageBlobDTO> blobDTOs = new ArrayList<>();
        for (Long blobId : dto.getStorageBlobIds()) {
            StorageBlobDTO blobDTO = new StorageBlobDTO();
            blobDTO.setId(blobId);
            blobDTOs.add(blobDTO);
        }
        attachmentDTO.setStorageBlobDTOs(blobDTOs);
        storageAttachmentService.saveStorageAttachment(attachmentDTO);
        // åˆ›å»ºå‘量记录并触发向量化
        for (Long blobId : dto.getStorageBlobIds()) {
            // èŽ·å–æ–‡ä»¶ä¿¡æ¯
            var blob = storageAttachmentService.getBaseMapper()
                    .selectOne(com.baomidou.mybatisplus.core.toolkit.Wrappers.<StorageAttachment>lambdaQuery()
                            .eq(StorageAttachment::getStorageBlobId, blobId)
                            .eq(StorageAttachment::getRecordType, "knowledge_base")
                            .eq(StorageAttachment::getRecordId, dto.getKnowledgeBaseId())
                            .last("limit 1"));
            if (blob != null) {
                // èŽ·å–æ–‡ä»¶åï¼Œéœ€è¦ä»Ž storage_blob è¡¨èŽ·å–
                // è¿™é‡Œç®€åŒ–处理,实际需要查询 storage_blob è¡¨
                String fileName = "file_" + blobId;
                String fileType = "unknown";
                knowledgeBaseVectorService.createVectorRecord(
                        dto.getKnowledgeBaseId(),
                        blobId,
                        fileName,
                        fileType
                );
            }
        }
        return AjaxResult.success();
    }
    /**
     * åˆ é™¤çŸ¥è¯†åº“文件
     */
    @DeleteMapping("/file/delete")
    @Operation(summary = "删除知识库文件")
    public AjaxResult deleteKnowledgeBaseFiles(@RequestBody List<Long> vectorIds) {
        if (CollectionUtils.isEmpty(vectorIds)) {
            return AjaxResult.error("请选择要删除的文件");
        }
        knowledgeBaseVectorService.deleteVectors(vectorIds);
        return AjaxResult.success();
    }
    /**
     * çŸ¥è¯†åº“文件DTO
     */
    @lombok.Data
    public static class KnowledgeBaseFileDTO {
        private Long knowledgeBaseId;
        private List<Long> storageBlobIds;
    }
}
src/main/java/com/ruoyi/approve/dto/KnowledgeBaseVectorVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
package com.ruoyi.approve.dto;
import com.ruoyi.approve.pojo.KnowledgeBaseVector;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
 * çŸ¥è¯†åº“文件向量状态VO
 */
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "知识库文件向量状态VO")
public class KnowledgeBaseVectorVO extends KnowledgeBaseVector {
    @Schema(description = "文件预览URL")
    private String previewUrl;
    @Schema(description = "文件下载URL")
    private String downloadUrl;
}
src/main/java/com/ruoyi/approve/mapper/KnowledgeBaseVectorMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,39 @@
package com.ruoyi.approve.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.approve.dto.KnowledgeBaseVectorVO;
import com.ruoyi.approve.pojo.KnowledgeBaseVector;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
 * çŸ¥è¯†åº“文件向量记录 Mapper
 */
@Mapper
public interface KnowledgeBaseVectorMapper extends BaseMapper<KnowledgeBaseVector> {
    /**
     * æŸ¥è¯¢çŸ¥è¯†åº“的文件向量状态列表
     */
    @Select("SELECT v.*, b.path as previewUrl " +
            "FROM knowledge_base_vector v " +
            "LEFT JOIN storage_blob b ON v.storage_blob_id = b.id " +
            "WHERE v.knowledge_base_id = #{knowledgeBaseId} " +
            "ORDER BY v.create_time DESC")
    List<KnowledgeBaseVectorVO> selectByKnowledgeBaseId(@Param("knowledgeBaseId") Long knowledgeBaseId);
    /**
     * ç»Ÿè®¡çŸ¥è¯†åº“的文件数量
     */
    @Select("SELECT COUNT(*) FROM knowledge_base_vector WHERE knowledge_base_id = #{knowledgeBaseId}")
    int countByKnowledgeBaseId(@Param("knowledgeBaseId") Long knowledgeBaseId);
    /**
     * ç»Ÿè®¡çŸ¥è¯†åº“的总切片数量
     */
    @Select("SELECT SUM(chunk_count) FROM knowledge_base_vector WHERE knowledge_base_id = #{knowledgeBaseId} AND vector_status = 2")
    int sumChunkCountByKnowledgeBaseId(@Param("knowledgeBaseId") Long knowledgeBaseId);
}
src/main/java/com/ruoyi/approve/pojo/KnowledgeBase.java
@@ -91,4 +91,19 @@
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
    /**
     * æ–‡ä»¶æ•°é‡
     */
    private Integer fileCount;
    /**
     * æ€»åˆ‡ç‰‡æ•°é‡
     */
    private Integer totalChunkCount;
    /**
     * çŸ¥è¯†åº“描述
     */
    private String description;
}
src/main/java/com/ruoyi/approve/pojo/KnowledgeBaseVector.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,80 @@
package com.ruoyi.approve.pojo;
import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
 * çŸ¥è¯†åº“文件向量记录表
 * knowledge_base_vector
 */
@Data
@TableName("knowledge_base_vector")
@Schema(description = "知识库文件向量记录")
public class KnowledgeBaseVector implements Serializable {
    private static final long serialVersionUID = 1L;
    @TableId(type = IdType.AUTO)
    @Schema(description = "主键ID")
    private Long id;
    @Schema(description = "关联知识库ID")
    private Long knowledgeBaseId;
    @Schema(description = "关联文件blob ID")
    private Long storageBlobId;
    @Schema(description = "文件名称")
    private String fileName;
    @Schema(description = "文件类型(docx/pdf/xlsx/txt等)")
    private String fileType;
    @Schema(description = "向量化状态: 0-待处理, 1-处理中, 2-已完成, 3-失败")
    private Integer vectorStatus;
    @Schema(description = "向量化失败原因")
    private String vectorError;
    @Schema(description = "切片数量")
    private Integer chunkCount;
    @Schema(description = "向量命名空间")
    private String namespace;
    @TableField(fill = FieldFill.INSERT)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @Schema(description = "创建时间")
    private LocalDateTime createTime;
    @TableField(fill = FieldFill.INSERT)
    @Schema(description = "创建人")
    private Integer createUser;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @Schema(description = "更新时间")
    private LocalDateTime updateTime;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    @Schema(description = "更新人")
    private Integer updateUser;
    @TableField(fill = FieldFill.INSERT)
    @Schema(description = "租户ID")
    private Long tenantId;
    @TableField(fill = FieldFill.INSERT)
    @Schema(description = "部门ID")
    private Long deptId;
    // å‘量化状态常量
    public static final int STATUS_PENDING = 0;
    public static final int STATUS_PROCESSING = 1;
    public static final int STATUS_COMPLETED = 2;
    public static final int STATUS_FAILED = 3;
}
src/main/java/com/ruoyi/approve/service/KnowledgeBaseVectorService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
package com.ruoyi.approve.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.ruoyi.approve.dto.KnowledgeBaseVectorVO;
import com.ruoyi.approve.pojo.KnowledgeBaseVector;
import java.util.List;
/**
 * çŸ¥è¯†åº“文件向量记录 Service
 */
public interface KnowledgeBaseVectorService extends IService<KnowledgeBaseVector> {
    /**
     * æŸ¥è¯¢çŸ¥è¯†åº“的文件向量状态列表
     */
    List<KnowledgeBaseVectorVO> getVectorStatusByKnowledgeBaseId(Long knowledgeBaseId);
    /**
     * åˆ›å»ºå‘量记录(文件上传后调用)
     */
    KnowledgeBaseVector createVectorRecord(Long knowledgeBaseId, Long storageBlobId, String fileName, String fileType);
    /**
     * æ›´æ–°å‘量化状态
     */
    void updateVectorStatus(Long id, Integer status, Integer chunkCount, String error);
    /**
     * é‡æ–°å¤„理向量化
     */
    void reprocessVector(Long id);
    /**
     * åˆ é™¤å‘量记录及向量库数据
     */
    void deleteVector(Long id);
    /**
     * æ‰¹é‡åˆ é™¤å‘量记录
     */
    void deleteVectors(List<Long> ids);
}
src/main/java/com/ruoyi/approve/service/impl/KnowledgeBaseVectorServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,144 @@
package com.ruoyi.approve.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.approve.dto.KnowledgeBaseVectorVO;
import com.ruoyi.approve.mapper.KnowledgeBaseVectorMapper;
import com.ruoyi.approve.pojo.KnowledgeBase;
import com.ruoyi.approve.pojo.KnowledgeBaseVector;
import com.ruoyi.approve.service.KnowledgeBaseService;
import com.ruoyi.approve.service.KnowledgeBaseVectorService;
import com.ruoyi.ai.service.KnowledgeRagService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
 * çŸ¥è¯†åº“文件向量记录 Service实现
 */
@Slf4j
@Service
public class KnowledgeBaseVectorServiceImpl extends ServiceImpl<KnowledgeBaseVectorMapper, KnowledgeBaseVector>
        implements KnowledgeBaseVectorService {
    private final KnowledgeBaseService knowledgeBaseService;
    private final KnowledgeRagService knowledgeRagService;
    public KnowledgeBaseVectorServiceImpl(
            KnowledgeBaseService knowledgeBaseService,
            @Lazy KnowledgeRagService knowledgeRagService) {
        this.knowledgeBaseService = knowledgeBaseService;
        this.knowledgeRagService = knowledgeRagService;
    }
    @Override
    public List<KnowledgeBaseVectorVO> getVectorStatusByKnowledgeBaseId(Long knowledgeBaseId) {
        return baseMapper.selectByKnowledgeBaseId(knowledgeBaseId);
    }
    @Override
    public KnowledgeBaseVector createVectorRecord(Long knowledgeBaseId, Long storageBlobId,
                                                   String fileName, String fileType) {
        KnowledgeBase knowledgeBase = knowledgeBaseService.getById(knowledgeBaseId);
        if (knowledgeBase == null) {
            throw new RuntimeException("知识库不存在: " + knowledgeBaseId);
        }
        KnowledgeBaseVector vector = new KnowledgeBaseVector();
        vector.setKnowledgeBaseId(knowledgeBaseId);
        vector.setStorageBlobId(storageBlobId);
        vector.setFileName(fileName);
        vector.setFileType(fileType);
        vector.setVectorStatus(KnowledgeBaseVector.STATUS_PENDING);
        vector.setNamespace("kb-" + knowledgeBaseId);
        vector.setChunkCount(0);
        save(vector);
        // å¼‚步触发向量化处理
        knowledgeRagService.processVectorAsync(vector.getId());
        return vector;
    }
    @Override
    public void updateVectorStatus(Long id, Integer status, Integer chunkCount, String error) {
        KnowledgeBaseVector vector = getById(id);
        if (vector == null) {
            return;
        }
        vector.setVectorStatus(status);
        if (chunkCount != null) {
            vector.setChunkCount(chunkCount);
        }
        if (error != null) {
            vector.setVectorError(error);
        }
        updateById(vector);
        // å¦‚果完成,更新知识库统计
        if (status == KnowledgeBaseVector.STATUS_COMPLETED) {
            updateKnowledgeBaseStats(vector.getKnowledgeBaseId());
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void reprocessVector(Long id) {
        KnowledgeBaseVector vector = getById(id);
        if (vector == null) {
            throw new RuntimeException("向量记录不存在: " + id);
        }
        vector.setVectorStatus(KnowledgeBaseVector.STATUS_PENDING);
        vector.setVectorError(null);
        vector.setChunkCount(0);
        updateById(vector);
        // å¼‚步重新处理
        knowledgeRagService.processVectorAsync(id);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteVector(Long id) {
        KnowledgeBaseVector vector = getById(id);
        if (vector == null) {
            return;
        }
        // åˆ é™¤å‘量库中的数据
        try {
            knowledgeRagService.deleteEmbeddings(vector.getNamespace(), vector.getStorageBlobId());
        } catch (Exception e) {
            log.error("删除向量库数据失败", e);
        }
        // åˆ é™¤è®°å½•
        removeById(id);
        // æ›´æ–°çŸ¥è¯†åº“统计
        updateKnowledgeBaseStats(vector.getKnowledgeBaseId());
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteVectors(List<Long> ids) {
        for (Long id : ids) {
            deleteVector(id);
        }
    }
    private void updateKnowledgeBaseStats(Long knowledgeBaseId) {
        KnowledgeBase knowledgeBase = knowledgeBaseService.getById(knowledgeBaseId);
        if (knowledgeBase == null) {
            return;
        }
        int fileCount = baseMapper.countByKnowledgeBaseId(knowledgeBaseId);
        int totalChunkCount = baseMapper.sumChunkCountByKnowledgeBaseId(knowledgeBaseId);
        knowledgeBase.setFileCount(fileCount);
        knowledgeBase.setTotalChunkCount(totalChunkCount);
        knowledgeBaseService.updateById(knowledgeBase);
    }
}
src/main/java/com/ruoyi/basic/dto/StorageBlobDTO.java
@@ -1,5 +1,7 @@
package com.ruoyi.basic.dto;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import com.ruoyi.basic.pojo.StorageBlob;
import lombok.Data;
@@ -19,4 +21,17 @@
     * æ–‡ä»¶ç±»åž‹
     */
    private String application;
    /**
     * æ”¯æŒä»Žæ•°å­—ID反序列化(前端可能只传ID)
     */
    @JsonCreator
    public static StorageBlobDTO from(Object value) {
        if (value instanceof Number) {
            StorageBlobDTO dto = new StorageBlobDTO();
            dto.setId(((Number) value).longValue());
            return dto;
        }
        throw new IllegalArgumentException("无法反序列化 StorageBlobDTO: " + value);
    }
}
src/main/java/com/ruoyi/basic/enums/ApplicationTypeEnum.java
@@ -5,7 +5,8 @@
    FILE("file"),
    AFTER_FILE("after_file"),
    BEFORE_FILE("before_file"),
    APK("apk");
    APK("apk"),
    RAG_FILE("rag_file");
    private final String type;
src/main/java/com/ruoyi/basic/utils/FileUtil.java
@@ -111,6 +111,7 @@
        if (CollectionUtils.isEmpty(storageBlobDTOS)) {
            deleteStorageAttachmentsByRecordTypeAndRecordId(recordType, recordId);
            return;
        }
        List<StorageAttachment> storageAttachments = new ArrayList<>();
src/main/java/com/ruoyi/quality/pojo/QualityInspect.java
@@ -164,6 +164,12 @@
    @Schema(description = "关联检测标准主表id")
    private Long testStandardId;
    /**
     * å…¥åº“比例(百分比),质检入库时入库数量=合格数量*入库比例/100
     */
    @Excel(name = "入库比例(%)")
    @Schema(description = "入库比例(百分比),默认100")
    private BigDecimal stockInRatio;
    @TableField(fill = FieldFill.INSERT)
    private Long deptId;
src/main/java/com/ruoyi/quality/service/impl/QualityInspectServiceImpl.java
@@ -137,7 +137,15 @@
            }
            stockInventoryDto.setRecordId(qualityInspect.getId());
            stockInventoryDto.setProductModelId(qualityInspect.getProductModelId());
            stockInventoryDto.setQualitity(qualityInspect.getQualifiedQuantity());
            // å…¥åº“数量 = åˆæ ¼æ•°é‡ * å…¥åº“比例 / 100,入库比例默认100%
            BigDecimal stockInRatio = qualityInspect.getStockInRatio();
            if (stockInRatio == null || stockInRatio.compareTo(BigDecimal.ZERO) <= 0) {
                stockInRatio = new BigDecimal("100.00");
            }
            BigDecimal actualStockInQuantity = qualityInspect.getQualifiedQuantity()
                    .multiply(stockInRatio)
                    .divide(new BigDecimal("100"), 2, BigDecimal.ROUND_HALF_UP);
            stockInventoryDto.setQualitity(actualStockInQuantity);
            if (qualityInspect.getCheckTime() != null) {
                LocalDate stockCreateDate = DateUtils.toLocalDate(qualityInspect.getCheckTime()).plusDays(1);
                stockInventoryDto.setCreateTime(LocalDateTime.of(stockCreateDate, java.time.LocalTime.MIDNIGHT));
@@ -294,7 +302,15 @@
            }
            stockInventoryDto.setRecordId(qualityInspect.getId());
            stockInventoryDto.setProductModelId(qualityInspect.getProductModelId());
            stockInventoryDto.setQualitity(qualified);
            // å…¥åº“数量 = åˆæ ¼æ•°é‡ * å…¥åº“比例 / 100,入库比例默认100%
            BigDecimal stockInRatio = qualityInspect.getStockInRatio();
            if (stockInRatio == null || stockInRatio.compareTo(BigDecimal.ZERO) <= 0) {
                stockInRatio = new BigDecimal("100.00");
            }
            BigDecimal actualStockInQuantity = qualified
                    .multiply(stockInRatio)
                    .divide(new BigDecimal("100"), 2, BigDecimal.ROUND_HALF_UP);
            stockInventoryDto.setQualitity(actualStockInQuantity);
            if (qualityInspect.getCheckTime() != null) {
                LocalDate stockCreateDate = DateUtils.toLocalDate(qualityInspect.getCheckTime()).plusDays(1);
                stockInventoryDto.setCreateTime(LocalDateTime.of(stockCreateDate, java.time.LocalTime.MIDNIGHT));
src/main/java/com/ruoyi/stock/service/impl/StockInRecordServiceImpl.java
@@ -55,7 +55,9 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int add(StockInRecordDto stockInRecordDto) {
        String no = OrderUtils.countTodayByCreateTime(stockInRecordMapper, "RK","inbound_batches", stockInRecordDto.getCreateTime() != null ? stockInRecordDto.getCreateTime() : LocalDateTime.now());
        LocalDateTime createTime = stockInRecordDto.getCreateTime() != null ? stockInRecordDto.getCreateTime() : LocalDateTime.now();
        stockInRecordDto.setCreateTime(createTime);
        String no = OrderUtils.countTodayByCreateTime(stockInRecordMapper, "RK","inbound_batches", createTime);
        stockInRecordDto.setInboundBatches(no);
        StockInRecord stockInRecord = new StockInRecord();
        BeanUtils.copyProperties(stockInRecordDto, stockInRecord);
src/main/java/com/ruoyi/stock/service/impl/StockOutRecordServiceImpl.java
@@ -60,9 +60,11 @@
    @Override
    public int add(StockOutRecordDto stockOutRecordDto) {
        LocalDateTime createTime = stockOutRecordDto.getCreateTime() != null ? stockOutRecordDto.getCreateTime() : LocalDateTime.now();
        stockOutRecordDto.setCreateTime(createTime);
        // å¦‚果传入了outboundBatches则使用,否则自动生成
        if (stockOutRecordDto.getOutboundBatches() == null || stockOutRecordDto.getOutboundBatches().isEmpty()) {
            String no = OrderUtils.countTodayByCreateTime(stockOutRecordMapper, "CK","outbound_batches", stockOutRecordDto.getCreateTime() != null ? stockOutRecordDto.getCreateTime() : LocalDateTime.now());
            String no = OrderUtils.countTodayByCreateTime(stockOutRecordMapper, "CK","outbound_batches", createTime);
            stockOutRecordDto.setOutboundBatches(no);
        }
        if (StockOutQualifiedRecordTypeEnum.SALE_SHIP_STOCK_OUT.getCode().equals(stockOutRecordDto.getRecordType())){
src/main/resources/application.yml
@@ -41,5 +41,3 @@
      model-name: "deepseek-r1:1.5b"
      log-requests: true
      log-responses: true
knowledge:
  one: D:\新疆大罗素企业产品体系说明文档.md