feat(enum): 统一结构化枚举值传输与同步
This commit is contained in:
29
AGENT.md
29
AGENT.md
@@ -147,6 +147,35 @@
|
|||||||
6. OpenAPI 注解覆盖
|
6. OpenAPI 注解覆盖
|
||||||
所有 Controller、DTO 使用 `@Tag`、`@Operation`、`@Schema` 注解。
|
所有 Controller、DTO 使用 `@Tag`、`@Operation`、`@Schema` 注解。
|
||||||
|
|
||||||
|
## 4.1 代码注释约定
|
||||||
|
|
||||||
|
为方便后续多人协作和 Agent 接力阅读,新增以下约定:
|
||||||
|
|
||||||
|
1. 新增或修改核心业务代码时,需要补充中文注释
|
||||||
|
注释优先覆盖类职责、关键方法、关键分支和重要参数含义,避免只写重复代码字面的无效注释。
|
||||||
|
|
||||||
|
2. 每次提交代码时,同步检查对应改动是否已经补齐中文注释
|
||||||
|
尤其是新引入的工厂、策略、服务编排、状态流转和复杂转换逻辑,默认需要有中文说明。
|
||||||
|
|
||||||
|
3. 注释以“帮助后来者快速理解设计意图”为目标
|
||||||
|
不追求注释数量,重点说明为什么这样做、边界是什么、哪些地方后续还会扩展。
|
||||||
|
|
||||||
|
## 4.2 结构化枚举约定
|
||||||
|
|
||||||
|
为保证前后端协议、代码定义和数据库配置一致,新增以下长期规则:
|
||||||
|
|
||||||
|
1. 长期固定的结构化文本字段,统一采用枚举值传输
|
||||||
|
不再以字符串名称作为接口协议值,前后端统一传整型枚举值。
|
||||||
|
|
||||||
|
2. 这类枚举必须先定义为 Java 枚举类
|
||||||
|
Java 枚举类作为单一事实来源,再派生前端常量和 `sys_enum` 配置。
|
||||||
|
|
||||||
|
3. 每次新增或修改结构化枚举时,必须同步纳入 `sys_enum` 初始化测试
|
||||||
|
通过统一测试入口按 `catalog + type` 先删后全量重建,避免数据库枚举配置漂移。
|
||||||
|
|
||||||
|
4. `catalog + type` 在枚举组层面必须唯一
|
||||||
|
一旦重复,会破坏枚举组重建语义,因此视为非法设计。
|
||||||
|
|
||||||
## 5. 数据与存储设计
|
## 5. 数据与存储设计
|
||||||
|
|
||||||
### 5.1 关系型数据库
|
### 5.1 关系型数据库
|
||||||
|
|||||||
@@ -162,6 +162,19 @@
|
|||||||
- **后端测试**:围绕结构约束的单元测试(Mapper/Service/Controller 继承体系、实体字段注解、方法签名验证)。
|
- **后端测试**:围绕结构约束的单元测试(Mapper/Service/Controller 继承体系、实体字段注解、方法签名验证)。
|
||||||
- **前端测试**:Vitest + @vue/test-utils,覆盖路由定义、布局组件、页面渲染、API 调用和 Long 类型解析。
|
- **前端测试**:Vitest + @vue/test-utils,覆盖路由定义、布局组件、页面渲染、API 调用和 Long 类型解析。
|
||||||
|
|
||||||
|
## 6.1 注释规范
|
||||||
|
|
||||||
|
- 新增或修改核心业务代码时,需补充中文注释,优先说明类职责、方法目的、关键判断和扩展边界。
|
||||||
|
- 每次提交代码时,需要同步检查本次改动是否已经补齐对应中文注释,避免后续阅读只能靠反推代码语义。
|
||||||
|
- 注释应聚焦设计意图和边界,不建议堆砌“变量赋值”“循环遍历”这类低价值说明。
|
||||||
|
|
||||||
|
## 6.2 结构化枚举规范
|
||||||
|
|
||||||
|
- 长期固定的结构化文本字段,统一采用整型枚举值作为前后端传输协议,不再直接传递字符串名称。
|
||||||
|
- 后端 Java 枚举类是这类结构化枚举的单一事实来源,前端常量和 `sys_enum` 数据都基于它同步。
|
||||||
|
- 新增或修改结构化枚举时,需要通过统一的枚举初始化测试按 `catalog + type` 先删后全量重建写入 `sys_enum`。
|
||||||
|
- 不同枚举组之间的 `catalog + type` 必须唯一,否则会破坏枚举组重建的确定性。
|
||||||
|
|
||||||
## 7. 当前不足
|
## 7. 当前不足
|
||||||
|
|
||||||
- RAG 尚未进入"可检索链路",当前完成上传与解析,但未完成切片、向量化和召回。
|
- RAG 尚未进入"可检索链路",当前完成上传与解析,但未完成切片、向量化和召回。
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Enum Value Transport And SysEnum Sync Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Unify long-lived structured text fields to use enum values for transport, and rebuild `sys_enum` from enum definitions through a delete-then-insert synchronization flow.
|
||||||
|
|
||||||
|
**Architecture:** Add a shared enum definition contract in the backend, let existing enums implement it, and refactor enum initialization to rebuild each `catalog + type` group from code. Update the RAG parse request and frontend page to submit enum values instead of enum names.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 21, Spring Boot, MyBatis-Plus, JUnit 5, Vue 3, TypeScript, Vitest
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Lock Request Protocol With Failing Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/test/java/com/bruce/rag/RagDocumentParseServiceImplTests.java`
|
||||||
|
- Modify: `frontend/src/pages/rag/__tests__/RagDocumentsPage.spec.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
- [ ] **Step 2: Run targeted backend and frontend tests to verify they fail because the old string protocol is still in place**
|
||||||
|
- [ ] **Step 3: Update assertions to require integer enum values for `chunkStrategy`**
|
||||||
|
- [ ] **Step 4: Re-run targeted tests and keep them red until implementation exists**
|
||||||
|
|
||||||
|
### Task 2: Implement Backend Enum Definition Contract
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/java/com/bruce/common/enums/PersistableSysEnumDefinition.java`
|
||||||
|
- Modify: `src/main/java/com/bruce/common/enums/EnableStatusEnum.java`
|
||||||
|
- Modify: `src/main/java/com/bruce/common/enums/CommonStatusEnum.java`
|
||||||
|
- Modify: `src/main/java/com/bruce/rag/enums/RagParseStatusEnum.java`
|
||||||
|
- Modify: `src/main/java/com/bruce/rag/enums/RagIndexStatusEnum.java`
|
||||||
|
- Modify: `src/main/java/com/bruce/rag/enums/RagChunkStrategyEnum.java`
|
||||||
|
- Modify: `src/test/java/com/bruce/common/enumconfig/EnumDefinitionTests.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write or extend failing tests for enum metadata access and stable value lookup**
|
||||||
|
- [ ] **Step 2: Run backend enum tests to verify failure**
|
||||||
|
- [ ] **Step 3: Implement the shared enum definition contract and make existing enums implement it**
|
||||||
|
- [ ] **Step 4: Add `fromValue(Integer)` support where needed and rerun tests to green**
|
||||||
|
|
||||||
|
### Task 3: Rebuild SysEnum Initialization Flow
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/test/java/com/bruce/common/enumconfig/SysEnumDataInitTests.java`
|
||||||
|
- Create or Modify: `src/test/java/com/bruce/common/enumconfig/...` supporting tests as needed
|
||||||
|
- Modify: `src/main/java/com/bruce/common/service/ISysEnumService.java`
|
||||||
|
- Modify: `src/main/java/com/bruce/common/service/impl/SysEnumServiceImpl.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for duplicate `catalog + type`, duplicate value detection, and delete-then-insert rebuild behavior**
|
||||||
|
- [ ] **Step 2: Run targeted backend tests to verify failure**
|
||||||
|
- [ ] **Step 3: Add service support for removing and rebuilding a whole enum group**
|
||||||
|
- [ ] **Step 4: Refactor enum init test to register enum groups, validate uniqueness, delete old rows, and batch insert the new rows**
|
||||||
|
- [ ] **Step 5: Re-run targeted backend tests to green**
|
||||||
|
|
||||||
|
### Task 4: Switch RAG Parse Request To Integer Enum Values
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/com/bruce/rag/dto/request/RagDocumentParseRequest.java`
|
||||||
|
- Modify: `src/main/java/com/bruce/rag/parse/RagChunkCommand.java`
|
||||||
|
- Modify: `src/main/java/com/bruce/rag/service/impl/RagDocumentParseServiceImpl.java`
|
||||||
|
- Modify: `src/test/java/com/bruce/rag/RagDocumentParseServiceImplTests.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Confirm failing backend parse tests expect integer values**
|
||||||
|
- [ ] **Step 2: Change DTOs and validation logic to use enum values**
|
||||||
|
- [ ] **Step 3: Re-run targeted backend parse tests to green**
|
||||||
|
|
||||||
|
### Task 5: Update Frontend To Use Enum Values
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/api/ragDocuments.ts`
|
||||||
|
- Modify: `frontend/src/pages/rag/RagDocumentsPage.vue`
|
||||||
|
- Modify: `frontend/src/pages/rag/__tests__/RagDocumentsPage.spec.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Confirm frontend parse request test is red for numeric enum values**
|
||||||
|
- [ ] **Step 2: Change API typing, page defaults, options, and comparisons to numeric enum values**
|
||||||
|
- [ ] **Step 3: Re-run targeted frontend tests to green**
|
||||||
|
|
||||||
|
### Task 6: Record Project Convention
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `AGENT.md`
|
||||||
|
- Modify: `docs/ARCHITECTURE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the long-term convention that stable structured text uses enum values for transport**
|
||||||
|
- [ ] **Step 2: Add the rule that enum changes must be synchronized into `sys_enum` through the initialization test**
|
||||||
|
- [ ] **Step 3: Do a final focused verification run**
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# 枚举值传输与 SysEnum 同步设计
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
当前系统中存在两类问题:
|
||||||
|
|
||||||
|
1. 前后端针对长期固定的结构化字段,仍然传递字符串名称,例如 `chunkStrategy` 传 `"FIXED_LENGTH"`。
|
||||||
|
2. `sys_enum` 初始化依赖测试类中逐条 `saveOrUpdate(...)`,新增或修改枚举时需要手工同步多处,且不会清理同一 `catalog/type` 下的历史脏数据。
|
||||||
|
|
||||||
|
这会导致协议冗余、前后端约束不统一,以及数据库枚举配置可能与代码定义漂移。
|
||||||
|
|
||||||
|
## 2. 目标
|
||||||
|
|
||||||
|
本次改造需要达成以下目标:
|
||||||
|
|
||||||
|
- 长期固定的结构化文本字段,统一采用枚举值传输,不再传名称字符串。
|
||||||
|
- 后端 Java 枚举成为结构化枚举的单一事实来源。
|
||||||
|
- `sys_enum` 初始化机制支持按 `catalog + type` 分组,先删后全量重建。
|
||||||
|
- 前端展示继续使用中文文案,但请求协议只传枚举值。
|
||||||
|
- 新增或修改枚举后,只需改枚举类并运行统一测试,即可完成数据库同步。
|
||||||
|
|
||||||
|
## 3. 范围
|
||||||
|
|
||||||
|
本次纳入统一规范的枚举包括:
|
||||||
|
|
||||||
|
- `EnableStatusEnum`
|
||||||
|
- `CommonStatusEnum`
|
||||||
|
- `RagParseStatusEnum`
|
||||||
|
- `RagIndexStatusEnum`
|
||||||
|
- `RagChunkStrategyEnum`
|
||||||
|
|
||||||
|
本次同时把 `RagDocumentParseRequest.chunkStrategy` 从字符串协议改为数值协议。
|
||||||
|
|
||||||
|
## 4. 设计方案
|
||||||
|
|
||||||
|
### 4.1 后端枚举契约
|
||||||
|
|
||||||
|
新增一个统一的枚举定义接口,用于描述可同步到 `sys_enum` 的枚举项。接口提供:
|
||||||
|
|
||||||
|
- `getCatalog()`
|
||||||
|
- `getType()`
|
||||||
|
- `getName()`
|
||||||
|
- `getValue()`
|
||||||
|
- `getStrvalue()`
|
||||||
|
- `getSort()`
|
||||||
|
- `getRemark()`
|
||||||
|
|
||||||
|
上述五个现有枚举类统一实现该接口,使代码层直接具备落库所需信息。
|
||||||
|
|
||||||
|
### 4.2 枚举组唯一性
|
||||||
|
|
||||||
|
每一组枚举通过 `catalog + type` 唯一标识,例如:
|
||||||
|
|
||||||
|
- `common / enable_status`
|
||||||
|
- `common / common_status`
|
||||||
|
- `rag / parse_status`
|
||||||
|
- `rag / index_status`
|
||||||
|
- `rag / chunk_strategy`
|
||||||
|
|
||||||
|
系统要求不同枚举组之间 `catalog + type` 不能重复,否则无法安全执行“先删后全加”。
|
||||||
|
|
||||||
|
### 4.3 SysEnum 初始化机制
|
||||||
|
|
||||||
|
重写 `SysEnumDataInitTests` 的初始化方式:
|
||||||
|
|
||||||
|
1. 收集所有需要同步的枚举组。
|
||||||
|
2. 校验每个枚举组内部是否存在重复 `value`、重复 `sort`,以及不同组之间是否存在重复 `catalog + type`。
|
||||||
|
3. 对每个枚举组先按 `catalog + type` 删除数据库中的旧枚举。
|
||||||
|
4. 将当前代码定义的整组枚举全量写入 `sys_enum`。
|
||||||
|
|
||||||
|
这样可以保证数据库状态始终与当前代码一致,而不是增量叠加。
|
||||||
|
|
||||||
|
### 4.4 后端请求协议
|
||||||
|
|
||||||
|
`RagDocumentParseRequest.chunkStrategy` 改为 `Integer`,只接收枚举值。
|
||||||
|
|
||||||
|
同时为 `RagChunkStrategyEnum` 增加按值解析的方法,例如 `fromValue(Integer value)`,供服务层进行校验和转换。
|
||||||
|
|
||||||
|
`RagChunkCommand.chunkStrategy` 也同步改为 `Integer`,保持链路一致。
|
||||||
|
|
||||||
|
### 4.5 前端协议与展示
|
||||||
|
|
||||||
|
前端不再传字符串联合类型,而是改成数值枚举常量,例如:
|
||||||
|
|
||||||
|
- `FIXED_LENGTH = 1`
|
||||||
|
- `DELIMITER = 5`
|
||||||
|
|
||||||
|
页面中的单选项 `value` 使用数值,展示文案仍使用中文 `label`。提交请求时只传枚举值。
|
||||||
|
|
||||||
|
### 4.6 Agent 协作约定
|
||||||
|
|
||||||
|
在 `AGENT.md` 中新增长期规则:
|
||||||
|
|
||||||
|
- 对长期固定的结构化文本字段,统一采用枚举值传输。
|
||||||
|
- 枚举定义必须落在 Java 枚举类中。
|
||||||
|
- 枚举变更需要同步纳入 `sys_enum` 初始化测试。
|
||||||
|
- 每次新增或修改枚举后,需运行对应测试完成数据库同步。
|
||||||
|
|
||||||
|
## 5. 错误处理与边界
|
||||||
|
|
||||||
|
- 如果请求传入不存在的枚举值,后端直接抛出非法参数异常。
|
||||||
|
- 如果某个枚举组定义了重复 `value` 或重复 `sort`,初始化测试直接失败。
|
||||||
|
- 如果两个枚举组使用了相同的 `catalog + type`,初始化测试直接失败。
|
||||||
|
- 如果前端传入旧字符串协议,后端不做兼容,统一按新协议处理。
|
||||||
|
|
||||||
|
## 6. 测试策略
|
||||||
|
|
||||||
|
后端:
|
||||||
|
|
||||||
|
- 扩展 `EnumDefinitionTests`,验证关键枚举值稳定。
|
||||||
|
- 为 `RagDocumentParseServiceImplTests` 增加数值协议断言和非法值校验。
|
||||||
|
- 为新的 `sys_enum` 全量初始化逻辑增加单元测试,验证唯一性校验和重建行为。
|
||||||
|
|
||||||
|
前端:
|
||||||
|
|
||||||
|
- 更新 `RagDocumentsPage.spec.ts`,断言解析请求提交数值枚举值。
|
||||||
|
- 验证页面仍然展示中文切片名称。
|
||||||
|
|
||||||
|
## 7. 预期结果
|
||||||
|
|
||||||
|
改造完成后:
|
||||||
|
|
||||||
|
- 前后端结构化字段协议更紧凑、更稳定。
|
||||||
|
- 枚举定义、前端传值和数据库配置三者一致。
|
||||||
|
- 新增枚举时有固定流程,不再依赖手工增量补数据。
|
||||||
@@ -47,13 +47,21 @@ export interface RagDocumentBatchUploadRequest {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RagChunkStrategy =
|
/**
|
||||||
| 'FIXED_LENGTH'
|
* RAG 切片策略枚举值。
|
||||||
| 'PARAGRAPH'
|
* <p>
|
||||||
| 'HEADING'
|
* 前后端统一传递枚举值,不再传递字符串名称。
|
||||||
| 'TABLE_ROW'
|
*/
|
||||||
| 'DELIMITER'
|
export const RAG_CHUNK_STRATEGY = {
|
||||||
| 'SEMANTIC';
|
FIXED_LENGTH: 1,
|
||||||
|
PARAGRAPH: 2,
|
||||||
|
HEADING: 3,
|
||||||
|
TABLE_ROW: 4,
|
||||||
|
DELIMITER: 5,
|
||||||
|
SEMANTIC: 6,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type RagChunkStrategy = (typeof RAG_CHUNK_STRATEGY)[keyof typeof RAG_CHUNK_STRATEGY];
|
||||||
|
|
||||||
export interface RagDocumentParseRequest {
|
export interface RagDocumentParseRequest {
|
||||||
documentIds: string[];
|
documentIds: string[];
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
parseRagDocuments,
|
parseRagDocuments,
|
||||||
queryRagDocuments,
|
queryRagDocuments,
|
||||||
saveRagDocument,
|
saveRagDocument,
|
||||||
|
RAG_CHUNK_STRATEGY,
|
||||||
type RagChunkStrategy,
|
type RagChunkStrategy,
|
||||||
type RagDocument,
|
type RagDocument,
|
||||||
} from '@/api/ragDocuments';
|
} from '@/api/ragDocuments';
|
||||||
@@ -49,19 +50,19 @@ const editForm = reactive({
|
|||||||
|
|
||||||
const parseForm = reactive({
|
const parseForm = reactive({
|
||||||
documentIds: [] as string[],
|
documentIds: [] as string[],
|
||||||
chunkStrategy: 'FIXED_LENGTH' as RagChunkStrategy,
|
chunkStrategy: RAG_CHUNK_STRATEGY.FIXED_LENGTH as RagChunkStrategy,
|
||||||
chunkSize: 800,
|
chunkSize: 800,
|
||||||
chunkOverlap: 120,
|
chunkOverlap: 120,
|
||||||
delimiter: '。',
|
delimiter: '。',
|
||||||
});
|
});
|
||||||
|
|
||||||
const chunkStrategyOptions: Array<{ label: string; value: RagChunkStrategy; description: string }> = [
|
const chunkStrategyOptions: Array<{ label: string; value: RagChunkStrategy; description: string }> = [
|
||||||
{ label: '固定长度切片', value: 'FIXED_LENGTH', description: '按指定长度和重叠长度切分通用文本' },
|
{ label: '固定长度切片', value: RAG_CHUNK_STRATEGY.FIXED_LENGTH, description: '按指定长度和重叠长度切分通用文本' },
|
||||||
{ label: '按段落切片', value: 'PARAGRAPH', description: '按空行、自然段落边界切分' },
|
{ label: '按段落切片', value: RAG_CHUNK_STRATEGY.PARAGRAPH, description: '按空行、自然段落边界切分' },
|
||||||
{ label: '按标题层级切片', value: 'HEADING', description: '按标题和章节层级组织内容' },
|
{ label: '按标题层级切片', value: RAG_CHUNK_STRATEGY.HEADING, description: '按标题和章节层级组织内容' },
|
||||||
{ label: '按表格行切片', value: 'TABLE_ROW', description: '适合 Excel 表格和结构化明细数据' },
|
{ label: '按表格行切片', value: RAG_CHUNK_STRATEGY.TABLE_ROW, description: '适合 Excel 表格和结构化明细数据' },
|
||||||
{ label: '按分隔符切片', value: 'DELIMITER', description: '按句号、换行符或自定义分隔符切分' },
|
{ label: '按分隔符切片', value: RAG_CHUNK_STRATEGY.DELIMITER, description: '按句号、换行符或自定义分隔符切分' },
|
||||||
{ label: '语义切片', value: 'SEMANTIC', description: '后续结合语义边界或模型能力切分' },
|
{ label: '语义切片', value: RAG_CHUNK_STRATEGY.SEMANTIC, description: '后续结合语义边界或模型能力切分' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const filteredRows = computed(() => {
|
const filteredRows = computed(() => {
|
||||||
@@ -138,7 +139,7 @@ function openParseDialog(rows: RagDocument[]) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
parseForm.documentIds = ids;
|
parseForm.documentIds = ids;
|
||||||
parseForm.chunkStrategy = 'FIXED_LENGTH';
|
parseForm.chunkStrategy = RAG_CHUNK_STRATEGY.FIXED_LENGTH;
|
||||||
parseForm.chunkSize = 800;
|
parseForm.chunkSize = 800;
|
||||||
parseForm.chunkOverlap = 120;
|
parseForm.chunkOverlap = 120;
|
||||||
parseForm.delimiter = '。';
|
parseForm.delimiter = '。';
|
||||||
@@ -467,13 +468,13 @@ onMounted(() => {
|
|||||||
</el-radio>
|
</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="parseForm.chunkStrategy === 'FIXED_LENGTH'" label="切片长度">
|
<el-form-item v-if="parseForm.chunkStrategy === RAG_CHUNK_STRATEGY.FIXED_LENGTH" label="切片长度">
|
||||||
<el-input-number v-model="parseForm.chunkSize" :min="100" :max="4000" :step="100" />
|
<el-input-number v-model="parseForm.chunkSize" :min="100" :max="4000" :step="100" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="parseForm.chunkStrategy === 'FIXED_LENGTH'" label="重叠长度">
|
<el-form-item v-if="parseForm.chunkStrategy === RAG_CHUNK_STRATEGY.FIXED_LENGTH" label="重叠长度">
|
||||||
<el-input-number v-model="parseForm.chunkOverlap" :min="0" :max="1000" :step="20" />
|
<el-input-number v-model="parseForm.chunkOverlap" :min="0" :max="1000" :step="20" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="parseForm.chunkStrategy === 'DELIMITER'" label="分隔符">
|
<el-form-item v-if="parseForm.chunkStrategy === RAG_CHUNK_STRATEGY.DELIMITER" label="分隔符">
|
||||||
<el-input v-model="parseForm.delimiter" maxlength="20" placeholder="如 。、换行符或自定义符号" />
|
<el-input v-model="parseForm.delimiter" maxlength="20" placeholder="如 。、换行符或自定义符号" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ vi.mock('@/api/ragStores', () => ({
|
|||||||
|
|
||||||
vi.mock('@/api/ragDocuments', () => ({
|
vi.mock('@/api/ragDocuments', () => ({
|
||||||
SOURCE_TYPE_RAG: 'RAG',
|
SOURCE_TYPE_RAG: 'RAG',
|
||||||
|
RAG_CHUNK_STRATEGY: {
|
||||||
|
FIXED_LENGTH: 1,
|
||||||
|
PARAGRAPH: 2,
|
||||||
|
HEADING: 3,
|
||||||
|
TABLE_ROW: 4,
|
||||||
|
DELIMITER: 5,
|
||||||
|
SEMANTIC: 6,
|
||||||
|
},
|
||||||
queryRagDocuments: vi.fn((query?: { storeId?: string }) => {
|
queryRagDocuments: vi.fn((query?: { storeId?: string }) => {
|
||||||
const rows = [
|
const rows = [
|
||||||
{
|
{
|
||||||
@@ -178,7 +186,7 @@ describe('RagDocumentsPage', () => {
|
|||||||
|
|
||||||
expect(parseRagDocuments).toHaveBeenCalledWith({
|
expect(parseRagDocuments).toHaveBeenCalledWith({
|
||||||
documentIds: ['11'],
|
documentIds: ['11'],
|
||||||
chunkStrategy: 'FIXED_LENGTH',
|
chunkStrategy: 1,
|
||||||
chunkSize: 800,
|
chunkSize: 800,
|
||||||
chunkOverlap: 120,
|
chunkOverlap: 120,
|
||||||
delimiter: '。',
|
delimiter: '。',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import lombok.Getter;
|
|||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public enum CommonStatusEnum {
|
public enum CommonStatusEnum implements PersistableSysEnumDefinition {
|
||||||
|
|
||||||
DISABLED(0, "禁用"),
|
DISABLED(0, "禁用"),
|
||||||
ENABLED(1, "启用"),
|
ENABLED(1, "启用"),
|
||||||
@@ -14,7 +14,28 @@ public enum CommonStatusEnum {
|
|||||||
COMPLETED(4, "已完成"),
|
COMPLETED(4, "已完成"),
|
||||||
FAILED(5, "失败");
|
FAILED(5, "失败");
|
||||||
|
|
||||||
|
private static final String CATALOG = "common";
|
||||||
|
|
||||||
|
private static final String TYPE = "common_status";
|
||||||
|
|
||||||
|
private static final String REMARK = "通用状态";
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
|
|
||||||
private final String label;
|
private final String label;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getCatalog() {
|
||||||
|
return CATALOG;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getType() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getRemark() {
|
||||||
|
return REMARK;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,33 @@ import lombok.Getter;
|
|||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public enum EnableStatusEnum {
|
public enum EnableStatusEnum implements PersistableSysEnumDefinition {
|
||||||
|
|
||||||
DISABLED(0, "禁用"),
|
DISABLED(0, "禁用"),
|
||||||
ENABLED(1, "启用");
|
ENABLED(1, "启用");
|
||||||
|
|
||||||
|
private static final String CATALOG = "common";
|
||||||
|
|
||||||
|
private static final String TYPE = "enable_status";
|
||||||
|
|
||||||
|
private static final String REMARK = "通用启用状态";
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
|
|
||||||
private final String label;
|
private final String label;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getCatalog() {
|
||||||
|
return CATALOG;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getType() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getRemark() {
|
||||||
|
return REMARK;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.bruce.common.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可同步到 sys_enum 的枚举定义契约。
|
||||||
|
* <p>
|
||||||
|
* 长期固定的结构化文本字段统一通过该契约描述,
|
||||||
|
* 便于前后端传值、数据库初始化和后续协作保持同一事实来源。
|
||||||
|
*/
|
||||||
|
public interface PersistableSysEnumDefinition {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 枚举所属模块目录,例如 common、rag。
|
||||||
|
*/
|
||||||
|
String getCatalog();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 枚举所属类型,同一 catalog/type 视为同一个枚举组。
|
||||||
|
*/
|
||||||
|
String getType();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 落库到 sys_enum.name 的展示名称。
|
||||||
|
*/
|
||||||
|
default String getName() {
|
||||||
|
return getLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 枚举整型值,前后端协议统一传该值。
|
||||||
|
*/
|
||||||
|
Integer getValue();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可选的字符串值,当前大多数业务枚举不使用。
|
||||||
|
*/
|
||||||
|
default String getStrvalue() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 排序值,默认与枚举值保持一致。
|
||||||
|
*/
|
||||||
|
default Integer getSort() {
|
||||||
|
return getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sys_enum.remark 中的说明文本。
|
||||||
|
*/
|
||||||
|
String getRemark();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 枚举显示文案,通常与 name 保持一致。
|
||||||
|
*/
|
||||||
|
String getLabel();
|
||||||
|
}
|
||||||
@@ -25,4 +25,6 @@ public interface ISysEnumService extends IService<SysEnum> {
|
|||||||
boolean saveOrUpdate(SysEnumSaveRequest request);
|
boolean saveOrUpdate(SysEnumSaveRequest request);
|
||||||
|
|
||||||
boolean batchSave(SysEnumBatchSaveRequest request);
|
boolean batchSave(SysEnumBatchSaveRequest request);
|
||||||
|
|
||||||
|
boolean replaceByCatalogAndType(String catalog, String type, List<SysEnum> items);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.bruce.common.dto.response.SysEnumResponse;
|
|||||||
import com.bruce.common.mapper.SysEnumMapper;
|
import com.bruce.common.mapper.SysEnumMapper;
|
||||||
import com.bruce.common.service.ISysEnumService;
|
import com.bruce.common.service.ISysEnumService;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
@@ -137,6 +138,24 @@ public class SysEnumServiceImpl extends ServiceImpl<SysEnumMapper, SysEnum> impl
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public boolean replaceByCatalogAndType(String catalog, String type, List<SysEnum> items) {
|
||||||
|
log.info("SysEnumServiceImpl.replaceByCatalogAndType start, catalog={}, type={}, itemCount={}",
|
||||||
|
catalog, type, items == null ? 0 : items.size());
|
||||||
|
validateReplaceByCatalogAndTypeRequest(catalog, type, items);
|
||||||
|
|
||||||
|
boolean removed = lambdaUpdate()
|
||||||
|
.eq(SysEnum::getCatalog, catalog)
|
||||||
|
.eq(SysEnum::getType, type)
|
||||||
|
.remove();
|
||||||
|
boolean saved = saveBatch(items);
|
||||||
|
boolean result = removed || saved;
|
||||||
|
log.info("SysEnumServiceImpl.replaceByCatalogAndType success, catalog={}, type={}, removed={}, saved={}, result={}",
|
||||||
|
catalog, type, removed, saved, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public void validateBatchSaveRequest(SysEnumBatchSaveRequest request, List<SysEnum> existingEnums) {
|
public void validateBatchSaveRequest(SysEnumBatchSaveRequest request, List<SysEnum> existingEnums) {
|
||||||
log.info("SysEnumServiceImpl.validateBatchSaveRequest start");
|
log.info("SysEnumServiceImpl.validateBatchSaveRequest start");
|
||||||
if (request == null) {
|
if (request == null) {
|
||||||
@@ -183,6 +202,44 @@ public class SysEnumServiceImpl extends ServiceImpl<SysEnumMapper, SysEnum> impl
|
|||||||
request.getCatalog(), request.getType(), requestValues.size(), existingValues.size());
|
request.getCatalog(), request.getType(), requestValues.size(), existingValues.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void validateReplaceByCatalogAndTypeRequest(String catalog, String type, List<SysEnum> items) {
|
||||||
|
if (!StringUtils.hasText(catalog)) {
|
||||||
|
throw new IllegalArgumentException("模块目录不能为空");
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(type)) {
|
||||||
|
throw new IllegalArgumentException("枚举类型不能为空");
|
||||||
|
}
|
||||||
|
if (items == null || items.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("枚举项不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<Integer> values = new HashSet<>();
|
||||||
|
Set<Integer> sorts = new HashSet<>();
|
||||||
|
for (SysEnum item : items) {
|
||||||
|
if (item == null) {
|
||||||
|
throw new IllegalArgumentException("枚举项不能为空");
|
||||||
|
}
|
||||||
|
if (!catalog.equals(item.getCatalog()) || !type.equals(item.getType())) {
|
||||||
|
throw new IllegalArgumentException("替换的枚举项 catalog/type 必须与目标分组一致");
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(item.getName())) {
|
||||||
|
throw new IllegalArgumentException("枚举名称不能为空");
|
||||||
|
}
|
||||||
|
if (item.getValue() == null) {
|
||||||
|
throw new IllegalArgumentException("枚举整型值不能为空");
|
||||||
|
}
|
||||||
|
if (!values.add(item.getValue())) {
|
||||||
|
throw new IllegalArgumentException("枚举值重复: " + item.getValue());
|
||||||
|
}
|
||||||
|
if (item.getSort() == null) {
|
||||||
|
throw new IllegalArgumentException("枚举排序不能为空");
|
||||||
|
}
|
||||||
|
if (!sorts.add(item.getSort())) {
|
||||||
|
throw new IllegalArgumentException("枚举排序重复: " + item.getSort());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private List<SysEnumResponse> toResponses(List<SysEnum> enums) {
|
private List<SysEnumResponse> toResponses(List<SysEnum> enums) {
|
||||||
return enums.stream()
|
return enums.stream()
|
||||||
.map(SysEnumResponse::fromEntity)
|
.map(SysEnumResponse::fromEntity)
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ public class RagDocumentParseRequest {
|
|||||||
@Schema(description = "文档ID列表")
|
@Schema(description = "文档ID列表")
|
||||||
private List<Long> documentIds;
|
private List<Long> documentIds;
|
||||||
|
|
||||||
@Schema(description = "切片方式")
|
@Schema(description = "切片方式枚举值")
|
||||||
private String chunkStrategy;
|
private Integer chunkStrategy;
|
||||||
|
|
||||||
@Schema(description = "切片长度")
|
@Schema(description = "切片长度")
|
||||||
private Integer chunkSize;
|
private Integer chunkSize;
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package com.bruce.rag.enums;
|
package com.bruce.rag.enums;
|
||||||
|
|
||||||
|
import com.bruce.common.enums.PersistableSysEnumDefinition;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public enum RagChunkStrategyEnum {
|
public enum RagChunkStrategyEnum implements PersistableSysEnumDefinition {
|
||||||
|
|
||||||
FIXED_LENGTH(1, "固定长度切片"),
|
FIXED_LENGTH(1, "固定长度切片"),
|
||||||
PARAGRAPH(2, "按段落切片"),
|
PARAGRAPH(2, "按段落切片"),
|
||||||
@@ -14,7 +17,35 @@ public enum RagChunkStrategyEnum {
|
|||||||
DELIMITER(5, "按分隔符切片"),
|
DELIMITER(5, "按分隔符切片"),
|
||||||
SEMANTIC(6, "语义切片");
|
SEMANTIC(6, "语义切片");
|
||||||
|
|
||||||
|
private static final String CATALOG = "rag";
|
||||||
|
|
||||||
|
private static final String TYPE = "chunk_strategy";
|
||||||
|
|
||||||
|
private static final String REMARK = "RAG文档切片方式";
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
|
|
||||||
private final String label;
|
private final String label;
|
||||||
|
|
||||||
|
public static RagChunkStrategyEnum fromValue(Integer value) {
|
||||||
|
return Arrays.stream(values())
|
||||||
|
.filter(item -> item.getValue().equals(value))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("不支持的切片方式: " + value));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getCatalog() {
|
||||||
|
return CATALOG;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getType() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getRemark() {
|
||||||
|
return REMARK;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,40 @@
|
|||||||
package com.bruce.rag.enums;
|
package com.bruce.rag.enums;
|
||||||
|
|
||||||
|
import com.bruce.common.enums.PersistableSysEnumDefinition;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public enum RagIndexStatusEnum {
|
public enum RagIndexStatusEnum implements PersistableSysEnumDefinition {
|
||||||
|
|
||||||
PENDING(1, "待索引"),
|
PENDING(1, "待索引"),
|
||||||
INDEXING(2, "索引中"),
|
INDEXING(2, "索引中"),
|
||||||
INDEXED(3, "已索引"),
|
INDEXED(3, "已索引"),
|
||||||
FAILED(4, "索引失败");
|
FAILED(4, "索引失败");
|
||||||
|
|
||||||
|
private static final String CATALOG = "rag";
|
||||||
|
|
||||||
|
private static final String TYPE = "index_status";
|
||||||
|
|
||||||
|
private static final String REMARK = "RAG文档索引状态";
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
|
|
||||||
private final String label;
|
private final String label;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getCatalog() {
|
||||||
|
return CATALOG;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getType() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getRemark() {
|
||||||
|
return REMARK;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,40 @@
|
|||||||
package com.bruce.rag.enums;
|
package com.bruce.rag.enums;
|
||||||
|
|
||||||
|
import com.bruce.common.enums.PersistableSysEnumDefinition;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public enum RagParseStatusEnum {
|
public enum RagParseStatusEnum implements PersistableSysEnumDefinition {
|
||||||
|
|
||||||
UPLOADED(1, "已上传"),
|
UPLOADED(1, "已上传"),
|
||||||
PARSING(2, "解析中"),
|
PARSING(2, "解析中"),
|
||||||
PARSED(3, "已解析"),
|
PARSED(3, "已解析"),
|
||||||
FAILED(4, "解析失败");
|
FAILED(4, "解析失败");
|
||||||
|
|
||||||
|
private static final String CATALOG = "rag";
|
||||||
|
|
||||||
|
private static final String TYPE = "parse_status";
|
||||||
|
|
||||||
|
private static final String REMARK = "RAG文档解析状态";
|
||||||
|
|
||||||
private final Integer value;
|
private final Integer value;
|
||||||
|
|
||||||
private final String label;
|
private final String label;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getCatalog() {
|
||||||
|
return CATALOG;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getType() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getRemark() {
|
||||||
|
return REMARK;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,7 @@ import org.springframework.util.StringUtils;
|
|||||||
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@@ -95,12 +92,7 @@ public class RagDocumentParseServiceImpl implements IRagDocumentParseService {
|
|||||||
if (request.getDocumentIds() == null || request.getDocumentIds().isEmpty()) {
|
if (request.getDocumentIds() == null || request.getDocumentIds().isEmpty()) {
|
||||||
throw new IllegalArgumentException("文档ID列表不能为空");
|
throw new IllegalArgumentException("文档ID列表不能为空");
|
||||||
}
|
}
|
||||||
Set<String> strategies = Arrays.stream(RagChunkStrategyEnum.values())
|
RagChunkStrategyEnum.fromValue(request.getChunkStrategy());
|
||||||
.map(Enum::name)
|
|
||||||
.collect(Collectors.toSet());
|
|
||||||
if (request.getChunkStrategy() == null || !strategies.contains(request.getChunkStrategy())) {
|
|
||||||
throw new IllegalArgumentException("不支持的切片方式: " + request.getChunkStrategy());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private DocumentParseContext buildParseContext(RagDocument document, SysAttachment attachment) {
|
private DocumentParseContext buildParseContext(RagDocument document, SysAttachment attachment) {
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package com.bruce.common.enumconfig;
|
|||||||
|
|
||||||
import com.bruce.common.enums.CommonStatusEnum;
|
import com.bruce.common.enums.CommonStatusEnum;
|
||||||
import com.bruce.common.enums.EnableStatusEnum;
|
import com.bruce.common.enums.EnableStatusEnum;
|
||||||
import com.bruce.rag.enums.RagIndexStatusEnum;
|
import com.bruce.common.enums.PersistableSysEnumDefinition;
|
||||||
import com.bruce.rag.enums.RagChunkStrategyEnum;
|
import com.bruce.rag.enums.RagChunkStrategyEnum;
|
||||||
|
import com.bruce.rag.enums.RagIndexStatusEnum;
|
||||||
import com.bruce.rag.enums.RagParseStatusEnum;
|
import com.bruce.rag.enums.RagParseStatusEnum;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
class EnumDefinitionTests {
|
class EnumDefinitionTests {
|
||||||
|
|
||||||
@@ -46,4 +48,33 @@ class EnumDefinitionTests {
|
|||||||
assertEquals("按分隔符切片", RagChunkStrategyEnum.DELIMITER.getLabel());
|
assertEquals("按分隔符切片", RagChunkStrategyEnum.DELIMITER.getLabel());
|
||||||
assertEquals("语义切片", RagChunkStrategyEnum.SEMANTIC.getLabel());
|
assertEquals("语义切片", RagChunkStrategyEnum.SEMANTIC.getLabel());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void enumsShouldExposeStableSysEnumMetadata() {
|
||||||
|
PersistableSysEnumDefinition chunkStrategy = RagChunkStrategyEnum.DELIMITER;
|
||||||
|
PersistableSysEnumDefinition parseStatus = RagParseStatusEnum.PARSED;
|
||||||
|
PersistableSysEnumDefinition enableStatus = EnableStatusEnum.ENABLED;
|
||||||
|
|
||||||
|
assertEquals("rag", chunkStrategy.getCatalog());
|
||||||
|
assertEquals("chunk_strategy", chunkStrategy.getType());
|
||||||
|
assertEquals("按分隔符切片", chunkStrategy.getName());
|
||||||
|
assertEquals(5, chunkStrategy.getValue());
|
||||||
|
assertEquals(5, chunkStrategy.getSort());
|
||||||
|
assertEquals("RAG文档切片方式", chunkStrategy.getRemark());
|
||||||
|
|
||||||
|
assertEquals("rag", parseStatus.getCatalog());
|
||||||
|
assertEquals("parse_status", parseStatus.getType());
|
||||||
|
assertEquals("已解析", parseStatus.getName());
|
||||||
|
|
||||||
|
assertEquals("common", enableStatus.getCatalog());
|
||||||
|
assertEquals("enable_status", enableStatus.getType());
|
||||||
|
assertEquals("启用", enableStatus.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ragChunkStrategyShouldResolveByIntegerValue() {
|
||||||
|
assertEquals(RagChunkStrategyEnum.FIXED_LENGTH, RagChunkStrategyEnum.fromValue(1));
|
||||||
|
assertEquals(RagChunkStrategyEnum.DELIMITER, RagChunkStrategyEnum.fromValue(5));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> RagChunkStrategyEnum.fromValue(999));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,4 +104,12 @@ class SysEnumComponentStructureTests {
|
|||||||
|
|
||||||
assertNotNull(initMethod);
|
assertNotNull(initMethod);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sysEnumServiceShouldExposeReplaceByCatalogAndType() throws NoSuchMethodException {
|
||||||
|
Method replaceMethod = ISysEnumService.class.getMethod("replaceByCatalogAndType", String.class, String.class, List.class);
|
||||||
|
|
||||||
|
assertNotNull(replaceMethod);
|
||||||
|
assertEquals(boolean.class, replaceMethod.getReturnType());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.bruce.common.enumconfig;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.bruce.common.domain.entity.SysEnum;
|
import com.bruce.common.domain.entity.SysEnum;
|
||||||
|
import com.bruce.common.enums.PersistableSysEnumDefinition;
|
||||||
import com.bruce.common.enums.CommonStatusEnum;
|
import com.bruce.common.enums.CommonStatusEnum;
|
||||||
import com.bruce.common.enums.EnableStatusEnum;
|
import com.bruce.common.enums.EnableStatusEnum;
|
||||||
import com.bruce.common.service.ISysEnumService;
|
import com.bruce.common.service.ISysEnumService;
|
||||||
@@ -13,6 +14,8 @@ import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@EnabledIfSystemProperty(named = "runEnumInit", matches = "true")
|
@EnabledIfSystemProperty(named = "runEnumInit", matches = "true")
|
||||||
class SysEnumDataInitTests {
|
class SysEnumDataInitTests {
|
||||||
@@ -22,49 +25,22 @@ class SysEnumDataInitTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void initDefaultEnums() {
|
public void initDefaultEnums() {
|
||||||
saveOrUpdate("common", "enable_status", EnableStatusEnum.DISABLED.getLabel(), EnableStatusEnum.DISABLED.getValue(), 0, "通用启用状态");
|
List<SysEnumDefinitionSyncSupport.EnumGroup> groups = List.of(
|
||||||
saveOrUpdate("common", "enable_status", EnableStatusEnum.ENABLED.getLabel(), EnableStatusEnum.ENABLED.getValue(), 1, "通用启用状态");
|
buildGroup(EnableStatusEnum.values()),
|
||||||
|
buildGroup(CommonStatusEnum.values()),
|
||||||
|
buildGroup(RagParseStatusEnum.values()),
|
||||||
|
buildGroup(RagIndexStatusEnum.values()),
|
||||||
|
buildGroup(RagChunkStrategyEnum.values())
|
||||||
|
);
|
||||||
|
SysEnumDefinitionSyncSupport.validateUniqueGroupKeys(groups);
|
||||||
|
|
||||||
saveOrUpdate("common", "common_status", CommonStatusEnum.DISABLED.getLabel(), CommonStatusEnum.DISABLED.getValue(), 0, "通用状态");
|
for (SysEnumDefinitionSyncSupport.EnumGroup group : groups) {
|
||||||
saveOrUpdate("common", "common_status", CommonStatusEnum.ENABLED.getLabel(), CommonStatusEnum.ENABLED.getValue(), 1, "通用状态");
|
List<SysEnum> rows = SysEnumDefinitionSyncSupport.toEntities(group);
|
||||||
saveOrUpdate("common", "common_status", CommonStatusEnum.DRAFT.getLabel(), CommonStatusEnum.DRAFT.getValue(), 2, "通用状态");
|
sysEnumService.replaceByCatalogAndType(group.catalog(), group.type(), rows);
|
||||||
saveOrUpdate("common", "common_status", CommonStatusEnum.PROCESSING.getLabel(), CommonStatusEnum.PROCESSING.getValue(), 3, "通用状态");
|
}
|
||||||
saveOrUpdate("common", "common_status", CommonStatusEnum.COMPLETED.getLabel(), CommonStatusEnum.COMPLETED.getValue(), 4, "通用状态");
|
|
||||||
saveOrUpdate("common", "common_status", CommonStatusEnum.FAILED.getLabel(), CommonStatusEnum.FAILED.getValue(), 5, "通用状态");
|
|
||||||
|
|
||||||
saveOrUpdate("rag", "parse_status", RagParseStatusEnum.UPLOADED.getLabel(), RagParseStatusEnum.UPLOADED.getValue(), 1, "RAG文档解析状态");
|
|
||||||
saveOrUpdate("rag", "parse_status", RagParseStatusEnum.PARSING.getLabel(), RagParseStatusEnum.PARSING.getValue(), 2, "RAG文档解析状态");
|
|
||||||
saveOrUpdate("rag", "parse_status", RagParseStatusEnum.PARSED.getLabel(), RagParseStatusEnum.PARSED.getValue(), 3, "RAG文档解析状态");
|
|
||||||
saveOrUpdate("rag", "parse_status", RagParseStatusEnum.FAILED.getLabel(), RagParseStatusEnum.FAILED.getValue(), 4, "RAG文档解析状态");
|
|
||||||
|
|
||||||
saveOrUpdate("rag", "index_status", RagIndexStatusEnum.PENDING.getLabel(), RagIndexStatusEnum.PENDING.getValue(), 1, "RAG文档索引状态");
|
|
||||||
saveOrUpdate("rag", "index_status", RagIndexStatusEnum.INDEXING.getLabel(), RagIndexStatusEnum.INDEXING.getValue(), 2, "RAG文档索引状态");
|
|
||||||
saveOrUpdate("rag", "index_status", RagIndexStatusEnum.INDEXED.getLabel(), RagIndexStatusEnum.INDEXED.getValue(), 3, "RAG文档索引状态");
|
|
||||||
saveOrUpdate("rag", "index_status", RagIndexStatusEnum.FAILED.getLabel(), RagIndexStatusEnum.FAILED.getValue(), 4, "RAG文档索引状态");
|
|
||||||
|
|
||||||
saveOrUpdate("rag", "chunk_strategy", RagChunkStrategyEnum.FIXED_LENGTH.getLabel(), RagChunkStrategyEnum.FIXED_LENGTH.getValue(), 1, "RAG文档切片方式");
|
|
||||||
saveOrUpdate("rag", "chunk_strategy", RagChunkStrategyEnum.PARAGRAPH.getLabel(), RagChunkStrategyEnum.PARAGRAPH.getValue(), 2, "RAG文档切片方式");
|
|
||||||
saveOrUpdate("rag", "chunk_strategy", RagChunkStrategyEnum.HEADING.getLabel(), RagChunkStrategyEnum.HEADING.getValue(), 3, "RAG文档切片方式");
|
|
||||||
saveOrUpdate("rag", "chunk_strategy", RagChunkStrategyEnum.TABLE_ROW.getLabel(), RagChunkStrategyEnum.TABLE_ROW.getValue(), 4, "RAG文档切片方式");
|
|
||||||
saveOrUpdate("rag", "chunk_strategy", RagChunkStrategyEnum.DELIMITER.getLabel(), RagChunkStrategyEnum.DELIMITER.getValue(), 5, "RAG文档切片方式");
|
|
||||||
saveOrUpdate("rag", "chunk_strategy", RagChunkStrategyEnum.SEMANTIC.getLabel(), RagChunkStrategyEnum.SEMANTIC.getValue(), 6, "RAG文档切片方式");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void saveOrUpdate(String catalog, String type, String name, Integer value, Integer sort, String remark) {
|
private SysEnumDefinitionSyncSupport.EnumGroup buildGroup(PersistableSysEnumDefinition[] definitions) {
|
||||||
SysEnum sysEnum = sysEnumService.getOne(new LambdaQueryWrapper<SysEnum>()
|
return SysEnumDefinitionSyncSupport.groupOf(List.of(definitions));
|
||||||
.eq(SysEnum::getCatalog, catalog)
|
|
||||||
.eq(SysEnum::getType, type)
|
|
||||||
.eq(SysEnum::getName, name));
|
|
||||||
if (sysEnum == null) {
|
|
||||||
sysEnum = new SysEnum();
|
|
||||||
}
|
|
||||||
sysEnum.setCatalog(catalog);
|
|
||||||
sysEnum.setType(type);
|
|
||||||
sysEnum.setName(name);
|
|
||||||
sysEnum.setValue(value);
|
|
||||||
sysEnum.setStrvalue(null);
|
|
||||||
sysEnum.setSort(sort);
|
|
||||||
sysEnum.setRemark(remark);
|
|
||||||
sysEnumService.saveOrUpdate(sysEnum);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.bruce.common.enumconfig;
|
||||||
|
|
||||||
|
import com.bruce.common.domain.entity.SysEnum;
|
||||||
|
import com.bruce.common.enums.PersistableSysEnumDefinition;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sys_enum 初始化测试的辅助工具。
|
||||||
|
* <p>
|
||||||
|
* 该类只服务于测试入口,用于把代码里的枚举定义组装成可落库的数据结构,
|
||||||
|
* 并在真正写库前完成组级唯一性校验。
|
||||||
|
*/
|
||||||
|
final class SysEnumDefinitionSyncSupport {
|
||||||
|
|
||||||
|
private SysEnumDefinitionSyncSupport() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static EnumGroup groupOf(List<? extends PersistableSysEnumDefinition> definitions) {
|
||||||
|
if (definitions == null || definitions.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("枚举定义不能为空");
|
||||||
|
}
|
||||||
|
PersistableSysEnumDefinition first = definitions.getFirst();
|
||||||
|
validateGroupMembers(first, definitions);
|
||||||
|
validateUniqueValuesAndSorts(first, definitions);
|
||||||
|
return new EnumGroup(first.getCatalog(), first.getType(), List.copyOf(definitions));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void validateUniqueGroupKeys(List<EnumGroup> groups) {
|
||||||
|
Set<String> keys = new HashSet<>();
|
||||||
|
for (EnumGroup group : groups) {
|
||||||
|
String key = group.catalog() + "/" + group.type();
|
||||||
|
if (!keys.add(key)) {
|
||||||
|
throw new IllegalArgumentException("存在重复的枚举分组: " + key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<SysEnum> toEntities(EnumGroup group) {
|
||||||
|
return group.definitions().stream()
|
||||||
|
.map(item -> {
|
||||||
|
SysEnum sysEnum = new SysEnum();
|
||||||
|
sysEnum.setCatalog(group.catalog());
|
||||||
|
sysEnum.setType(group.type());
|
||||||
|
sysEnum.setName(item.getName());
|
||||||
|
sysEnum.setValue(item.getValue());
|
||||||
|
sysEnum.setStrvalue(item.getStrvalue());
|
||||||
|
sysEnum.setSort(item.getSort());
|
||||||
|
sysEnum.setRemark(item.getRemark());
|
||||||
|
return sysEnum;
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateGroupMembers(
|
||||||
|
PersistableSysEnumDefinition first,
|
||||||
|
List<? extends PersistableSysEnumDefinition> definitions
|
||||||
|
) {
|
||||||
|
for (PersistableSysEnumDefinition item : definitions) {
|
||||||
|
if (!first.getCatalog().equals(item.getCatalog()) || !first.getType().equals(item.getType())) {
|
||||||
|
throw new IllegalArgumentException("同一枚举组中的 catalog/type 必须一致");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateUniqueValuesAndSorts(
|
||||||
|
PersistableSysEnumDefinition first,
|
||||||
|
List<? extends PersistableSysEnumDefinition> definitions
|
||||||
|
) {
|
||||||
|
Set<Integer> values = new HashSet<>();
|
||||||
|
Set<Integer> sorts = new HashSet<>();
|
||||||
|
for (PersistableSysEnumDefinition item : definitions) {
|
||||||
|
if (!values.add(item.getValue())) {
|
||||||
|
throw new IllegalArgumentException("枚举值重复: " + first.getCatalog() + "/" + first.getType() + "/" + item.getValue());
|
||||||
|
}
|
||||||
|
if (!sorts.add(item.getSort())) {
|
||||||
|
throw new IllegalArgumentException("枚举排序重复: " + first.getCatalog() + "/" + first.getType() + "/" + item.getSort());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record EnumGroup(
|
||||||
|
String catalog,
|
||||||
|
String type,
|
||||||
|
List<? extends PersistableSysEnumDefinition> definitions
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.bruce.common.enumconfig;
|
||||||
|
|
||||||
|
import com.bruce.common.domain.entity.SysEnum;
|
||||||
|
import com.bruce.common.enums.EnableStatusEnum;
|
||||||
|
import com.bruce.common.enums.PersistableSysEnumDefinition;
|
||||||
|
import com.bruce.rag.enums.RagChunkStrategyEnum;
|
||||||
|
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;
|
||||||
|
|
||||||
|
class SysEnumDefinitionSyncSupportTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectDuplicateCatalogAndTypeAcrossGroups() {
|
||||||
|
SysEnumDefinitionSyncSupport.EnumGroup left = SysEnumDefinitionSyncSupport.groupOf(
|
||||||
|
List.of(EnableStatusEnum.DISABLED, EnableStatusEnum.ENABLED)
|
||||||
|
);
|
||||||
|
SysEnumDefinitionSyncSupport.EnumGroup right = SysEnumDefinitionSyncSupport.groupOf(
|
||||||
|
List.of(
|
||||||
|
new FakeEnumDefinition("common", "enable_status", "草稿", 2, 2, "通用启用状态")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> SysEnumDefinitionSyncSupport.validateUniqueGroupKeys(List.of(left, right))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldBuildSysEnumRowsFromDefinitions() {
|
||||||
|
SysEnumDefinitionSyncSupport.EnumGroup group = SysEnumDefinitionSyncSupport.groupOf(
|
||||||
|
List.of(RagChunkStrategyEnum.FIXED_LENGTH, RagChunkStrategyEnum.DELIMITER)
|
||||||
|
);
|
||||||
|
|
||||||
|
List<SysEnum> rows = SysEnumDefinitionSyncSupport.toEntities(group);
|
||||||
|
|
||||||
|
assertEquals(2, rows.size());
|
||||||
|
assertEquals("rag", rows.get(0).getCatalog());
|
||||||
|
assertEquals("chunk_strategy", rows.get(0).getType());
|
||||||
|
assertEquals("固定长度切片", rows.get(0).getName());
|
||||||
|
assertEquals(1, rows.get(0).getValue());
|
||||||
|
assertEquals(5, rows.get(1).getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
private record FakeEnumDefinition(
|
||||||
|
String catalog,
|
||||||
|
String type,
|
||||||
|
String name,
|
||||||
|
Integer value,
|
||||||
|
Integer sort,
|
||||||
|
String remark
|
||||||
|
) implements PersistableSysEnumDefinition {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getCatalog() {
|
||||||
|
return catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getLabel() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer getValue() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getStrvalue() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Integer getSort() {
|
||||||
|
return sort;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getRemark() {
|
||||||
|
return remark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import com.bruce.common.service.ISysAttachmentService;
|
|||||||
import com.bruce.rag.dto.request.RagDocumentParseRequest;
|
import com.bruce.rag.dto.request.RagDocumentParseRequest;
|
||||||
import com.bruce.rag.dto.response.RagDocumentParseResponse;
|
import com.bruce.rag.dto.response.RagDocumentParseResponse;
|
||||||
import com.bruce.rag.entity.RagDocument;
|
import com.bruce.rag.entity.RagDocument;
|
||||||
|
import com.bruce.rag.enums.RagChunkStrategyEnum;
|
||||||
import com.bruce.rag.enums.RagParseStatusEnum;
|
import com.bruce.rag.enums.RagParseStatusEnum;
|
||||||
import com.bruce.rag.service.IRagDocumentService;
|
import com.bruce.rag.service.IRagDocumentService;
|
||||||
import com.bruce.rag.service.impl.RagDocumentParseServiceImpl;
|
import com.bruce.rag.service.impl.RagDocumentParseServiceImpl;
|
||||||
@@ -26,6 +27,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
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.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -121,7 +123,7 @@ class RagDocumentParseServiceImplTests {
|
|||||||
);
|
);
|
||||||
RagDocumentParseRequest request = new RagDocumentParseRequest();
|
RagDocumentParseRequest request = new RagDocumentParseRequest();
|
||||||
request.setDocumentIds(List.of(1002L));
|
request.setDocumentIds(List.of(1002L));
|
||||||
request.setChunkStrategy("DELIMITER");
|
request.setChunkStrategy(RagChunkStrategyEnum.DELIMITER.getValue());
|
||||||
request.setDelimiter("。");
|
request.setDelimiter("。");
|
||||||
|
|
||||||
when(ragDocumentService.getById(1002L)).thenReturn(document);
|
when(ragDocumentService.getById(1002L)).thenReturn(document);
|
||||||
@@ -135,6 +137,23 @@ class RagDocumentParseServiceImplTests {
|
|||||||
assertEquals(RagParseStatusEnum.PARSED.name(), responses.getFirst().getParseStatus());
|
assertEquals(RagParseStatusEnum.PARSED.name(), responses.getFirst().getParseStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseShouldRejectUnknownChunkStrategyValue() {
|
||||||
|
AttachmentProperties attachmentProperties = new AttachmentProperties();
|
||||||
|
attachmentProperties.setBasePath(tempDir.toString());
|
||||||
|
RagDocumentParseServiceImpl service = new RagDocumentParseServiceImpl(
|
||||||
|
ragDocumentService,
|
||||||
|
sysAttachmentService,
|
||||||
|
attachmentProperties,
|
||||||
|
new DocumentParserFactory(List.of(new FixedDocumentParser("batch profiles")))
|
||||||
|
);
|
||||||
|
RagDocumentParseRequest request = new RagDocumentParseRequest();
|
||||||
|
request.setDocumentIds(List.of(1002L));
|
||||||
|
request.setChunkStrategy(999);
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> service.parse(request));
|
||||||
|
}
|
||||||
|
|
||||||
private static class FixedDocumentParser implements DocumentParser {
|
private static class FixedDocumentParser implements DocumentParser {
|
||||||
|
|
||||||
private final String text;
|
private final String text;
|
||||||
|
|||||||
Reference in New Issue
Block a user