Auto generate id instead of manually input
This commit is contained in:
258
README.md
Normal file
258
README.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# 实时通话 ASR 事件监控
|
||||
|
||||
一个基于 Spring Boot、React 和 AC 自动机的实时通话关键词监控系统。
|
||||
前端持续提交市民与坐席的 ASR Final 文本,后端按通话维护上下文,匹配当前生效规则,并返回本次新增告警和通话累计结果。
|
||||
|
||||
## 主要功能
|
||||
|
||||
- 同时处理市民(`citizen`)和坐席(`agent`)文本
|
||||
- 按 `callId` 维护共享的最近 ASR Final 上下文
|
||||
- AC 自动机多关键词高效匹配
|
||||
- 同一通话、同一事件只首次告警
|
||||
- `callId + seq` 请求幂等
|
||||
- 页面化规则新增、编辑、启停、删除与热发布
|
||||
- 新增规则自动生成稳定的事件 ID,无需人工填写
|
||||
- Excel 模板下载、规则导入和当前规则导出
|
||||
- 历史版本查看与回溯;回溯旧内容时创建新版本
|
||||
- 通话主动关闭与 TTL 自动清理
|
||||
- H2 文件数据库持久化规则版本
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 模块 | 技术 |
|
||||
| --- | --- |
|
||||
| 后端 | Java 21、Spring Boot 4、Spring MVC、Spring Data JPA |
|
||||
| 匹配 | Aho-Corasick 自动机 |
|
||||
| 数据库 | H2 文件数据库 |
|
||||
| 前端 | React 19、TypeScript、Vite、Tailwind CSS、Motion |
|
||||
| Excel | ExcelJS |
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
.
|
||||
├── frontend/ # React 前端
|
||||
├── src/main/java/com/example/demo/
|
||||
│ ├── api/ # REST 接口
|
||||
│ ├── application/ # 监控、规则和会话服务
|
||||
│ ├── domain/ # 请求、响应和领域模型
|
||||
│ ├── matcher/ # AC 自动机及匹配器
|
||||
│ ├── repository/ # 规则版本和会话存储
|
||||
│ ├── job/ # 过期会话清理
|
||||
│ └── support/ # 异常、文本标准化等
|
||||
├── src/main/resources/
|
||||
│ ├── application.yml # 服务配置
|
||||
│ └── rules/default-rules.json # 初始规则
|
||||
└── pom.xml
|
||||
```
|
||||
|
||||
## 本地运行
|
||||
|
||||
### 环境要求
|
||||
|
||||
- JDK 21
|
||||
- Node.js 18 或更高版本
|
||||
- npm
|
||||
|
||||
### 1. 启动后端
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```bash
|
||||
./mvnw spring-boot:run
|
||||
```
|
||||
|
||||
后端默认地址:`http://localhost:8080`
|
||||
|
||||
健康检查:
|
||||
|
||||
```text
|
||||
http://localhost:8080/actuator/health
|
||||
```
|
||||
|
||||
### 2. 启动前端
|
||||
|
||||
另开一个终端:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
访问:`http://localhost:3000`
|
||||
|
||||
开发环境下,Vite 会将 `/api` 自动代理到 `http://localhost:8080`。
|
||||
|
||||
## 打包为单个 Spring Boot JAR
|
||||
|
||||
先构建前端并复制静态文件:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
cp -R frontend/dist/. src/main/resources/static/
|
||||
```
|
||||
|
||||
再打包和启动后端:
|
||||
|
||||
```bash
|
||||
./mvnw clean package
|
||||
java -jar target/demo-0.0.1-SNAPSHOT.jar
|
||||
```
|
||||
|
||||
访问:`http://localhost:8080`
|
||||
|
||||
## ASR 接口
|
||||
|
||||
提交一条 ASR Final:
|
||||
|
||||
```http
|
||||
POST /api/v1/asr-events
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"callId": "call-001",
|
||||
"seq": 1,
|
||||
"speaker": "citizen",
|
||||
"text": "我的顺丰快递一直没有收到",
|
||||
"final": true
|
||||
}
|
||||
```
|
||||
|
||||
`speaker` 只支持:
|
||||
|
||||
- `citizen`:市民
|
||||
- `agent`:坐席
|
||||
|
||||
同一 `callId` 下,两个角色必须共享一套全局唯一的 `seq`:
|
||||
|
||||
```text
|
||||
seq 1 citizen 市民发言
|
||||
seq 2 agent 坐席发言
|
||||
seq 3 citizen 市民发言
|
||||
seq 4 agent 坐席发言
|
||||
```
|
||||
|
||||
响应中的主要字段:
|
||||
|
||||
- `newAlerts`:本次新触发、需要前端提醒的事件
|
||||
- `currentResults`:该通话当前累计命中的事件
|
||||
- `duplicate`:是否为重复的 `callId + seq`
|
||||
- `activeRuleVersion`:本次匹配使用的规则版本
|
||||
|
||||
通话结束后应主动释放会话:
|
||||
|
||||
```http
|
||||
POST /api/v1/calls/{callId}/close
|
||||
```
|
||||
|
||||
## 算法流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["接收 ASR 请求"] --> B{"final=true?"}
|
||||
B -- 否 --> C["返回当前结果,不写入上下文"]
|
||||
B -- 是 --> D{"callId + seq 是否重复?"}
|
||||
D -- 是 --> E["返回 duplicate=true"]
|
||||
D -- 否 --> F["写入该通话的共享上下文窗口"]
|
||||
F --> G["拼接最近 N 条 Final"]
|
||||
G --> H["文本标准化"]
|
||||
H --> I["AC 自动机匹配全部关键词"]
|
||||
I --> J["合并事件并进行通话级去重"]
|
||||
J --> K["返回 newAlerts 和 currentResults"]
|
||||
```
|
||||
|
||||
### 1. 通话隔离与并发
|
||||
|
||||
每个 `callId` 拥有独立会话状态和锁。同一通话按锁串行处理,不同通话可以并行处理。
|
||||
|
||||
### 2. 共享上下文窗口
|
||||
|
||||
市民和坐席文本进入同一个、按 `seq` 排序的最近 N 条窗口。默认保留 5 条,能够识别被 ASR 切分到相邻 Final 中的关键词。
|
||||
|
||||
### 3. 文本标准化
|
||||
|
||||
匹配前会进行 Unicode NFKC 标准化、转小写,并移除空白和常见中英文标点。例如:
|
||||
|
||||
```text
|
||||
E M S → ems
|
||||
顺丰,快递 → 顺丰快递
|
||||
```
|
||||
|
||||
### 4. AC 自动机匹配
|
||||
|
||||
所有已启用关键词被一次性构建为不可变 AC 自动机快照。每条匹配文本只需扫描一次即可发现多个关键词,适合规则和关键词数量持续增加的场景。
|
||||
|
||||
### 5. 告警去重
|
||||
|
||||
匹配结果按业务事件聚合。同一个事件在同一次通话中只进入一次 `newAlerts`,重复命中仍保留在累计状态中,但不会重复提醒。
|
||||
|
||||
## 规则管理与版本回溯
|
||||
|
||||
规则保存采用完整文档发布,并携带 `baseVersion` 做乐观并发控制。事务提交成功后才切换运行时 AC 自动机快照。
|
||||
|
||||
主要接口:
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/admin/rules` | 获取当前生效规则 |
|
||||
| PUT | `/api/v1/admin/rules` | 保存并发布新版本 |
|
||||
| GET | `/api/v1/admin/rules/versions?limit=20` | 获取历史版本 |
|
||||
| POST | `/api/v1/admin/rules/versions/{version}/restore` | 回溯历史版本 |
|
||||
|
||||
回溯不会修改或删除旧记录。例如当前 V8 回溯到 V3,系统会复制 V3 的内容并创建新的 V9,然后切换 V9 为生效版本。
|
||||
|
||||
## 配置
|
||||
|
||||
主要配置位于 `src/main/resources/application.yml`:
|
||||
|
||||
| 配置 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `server.port` | `8080` | HTTP 端口 |
|
||||
| `monitor.recent-final-window-size` | `5` | 每通话保留的最近 Final 数量 |
|
||||
| `monitor.max-processed-seqs` | `500` | 幂等序列号历史上限 |
|
||||
| `monitor.session-ttl` | `PT2H` | 会话空闲过期时间 |
|
||||
| `monitor.session-cleanup-interval` | `PT10M` | 过期会话清理周期 |
|
||||
|
||||
规则数据库默认保存在项目运行目录的 `data/rules.mv.db`。
|
||||
|
||||
## 扩展性
|
||||
|
||||
### 扩展规则
|
||||
|
||||
直接通过前端页面或 Excel 导入新增关键词规则,无需修改匹配代码。发布后自动构建新快照并热切换。
|
||||
|
||||
### 扩展匹配算法
|
||||
|
||||
当前 `matcher` 为 `ac-keyword`。可以继续实现正则、语义分类或大模型匹配器,并通过统一的匹配接口进行组合。建议将确定性关键词匹配保持在同步主链路,将耗时模型调用放入异步链路。
|
||||
|
||||
### 扩展事件处理
|
||||
|
||||
可以在产生 `newAlerts` 后增加消息队列、WebSocket、工单、短信或外部系统通知。建议通过领域事件异步处理,避免阻塞 ASR 接口。
|
||||
|
||||
### 扩展存储
|
||||
|
||||
开发环境使用 H2。生产环境可以迁移到 MySQL 或 PostgreSQL;JPA 领域模型和服务层不需要大幅调整。
|
||||
|
||||
### 扩展为多实例部署
|
||||
|
||||
当前通话上下文保存在单机 JVM 内存。多实例部署时需要选择以下方案之一:
|
||||
|
||||
- 按 `callId` 做一致性路由,保证同一通话始终进入同一实例
|
||||
- 将会话上下文和幂等状态迁移到 Redis 等共享存储
|
||||
- 使用分布式事件流按 `callId` 分区处理
|
||||
|
||||
### 安全与治理
|
||||
|
||||
生产环境建议增加管理员认证、规则变更审计、接口限流、数据库迁移工具,以及历史版本保留策略。
|
||||
|
||||
## 其他说明
|
||||
|
||||
- 前端对话中的关键词高亮是本地视觉提示。
|
||||
- 真实告警始终以后端 `/api/v1/asr-events` 响应为准。
|
||||
- 更完整的设计背景见 `asr_event_monitor_confirmed_technical_solution.md`。
|
||||
@@ -1149,7 +1149,7 @@ public void activate(RuleActivatedEvent event) {
|
||||
保存前至少校验:
|
||||
|
||||
1. 规则集 ID 不重复
|
||||
2. 事件 ID 不重复
|
||||
2. 事件 ID 由系统自动生成;导入或兼容旧数据携带 ID 时必须保持唯一
|
||||
3. 事件名称不能为空
|
||||
4. 启用事件至少有一个关键词
|
||||
5. 标准化后关键词不能为空
|
||||
@@ -1160,6 +1160,8 @@ public void activate(RuleActivatedEvent event) {
|
||||
|
||||
对于“闪购”“邮政”“百度”等较宽泛关键词,可以作为 warning 返回,但首期允许保存。
|
||||
|
||||
前端新增规则时生成只读事件 ID,Excel 新增行允许将事件 ID 留空;后端保存时仍会为缺失 ID 生成 `event-<UUID>`,作为绕过前端调用接口时的兜底。已有规则的事件 ID 保持不变,以保证同一通话内事件去重和历史版本回溯的稳定性。
|
||||
|
||||
---
|
||||
|
||||
## 21. 单页面前端
|
||||
|
||||
@@ -44,6 +44,7 @@ src/main/resources/static/
|
||||
|
||||
- “模板下载”生成 `.xlsx` 规则模板。
|
||||
- 每行填写一条规则,多个关键词在同一个单元格内换行填写。
|
||||
- “导入”会校验事件 ID、启用状态、关键词以及重复数据,并替换页面工作副本。
|
||||
- 新增规则无需填写事件 ID,页面和 Excel 导入都会自动生成唯一 ID。
|
||||
- “导入”会校验已有事件 ID、启用状态、关键词以及重复数据,并替换页面工作副本。
|
||||
- “导出”按照相同模板格式导出当前页面中的规则。
|
||||
- 导入不会自动发布,确认数据后仍需点击“保存并发布”。
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
exportRulesToExcel,
|
||||
importRulesFromExcel,
|
||||
} from '../rule-excel';
|
||||
import { generateEventId } from '../rule-id';
|
||||
|
||||
interface RuleManagementProps {
|
||||
rules: Rule[];
|
||||
@@ -140,7 +141,7 @@ export default function RuleManagement({
|
||||
// Open Drawer for Add
|
||||
const handleAddRuleClick = () => {
|
||||
setEditingRule(null);
|
||||
setFormId('');
|
||||
setFormId(generateEventId(rules.map(rule => rule.id)));
|
||||
setFormName('');
|
||||
setFormKeywords([]);
|
||||
setKeywordInput('');
|
||||
@@ -210,13 +211,12 @@ export default function RuleManagement({
|
||||
const handleSaveForm = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const errors: Record<string, string> = {};
|
||||
const normalizedFormId = formId.trim() || generateEventId(rules.map(rule => rule.id));
|
||||
|
||||
if (!formId.trim()) {
|
||||
errors.id = '事件 ID 不能为空';
|
||||
} else if (!/^[a-zA-Z0-9_-]+$/.test(formId)) {
|
||||
errors.id = '事件 ID 仅支持英文、数字、中划线和下划线';
|
||||
} else if (!editingRule && rules.some(r => r.id === formId.trim())) {
|
||||
errors.id = '此事件 ID 已存在';
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(normalizedFormId)) {
|
||||
errors.id = '系统生成事件 ID 失败,请重新打开新增窗口';
|
||||
} else if (!editingRule && rules.some(r => r.id === normalizedFormId)) {
|
||||
errors.id = '系统生成的事件 ID 冲突,请重新打开新增窗口';
|
||||
}
|
||||
|
||||
if (!formName.trim()) {
|
||||
@@ -234,7 +234,7 @@ export default function RuleManagement({
|
||||
}
|
||||
|
||||
const newRule: Rule = {
|
||||
id: formId.trim(),
|
||||
id: normalizedFormId,
|
||||
name: formName.trim(),
|
||||
keywords: formKeywords,
|
||||
enabled: editingRule ? editingRule.enabled : true
|
||||
@@ -537,25 +537,17 @@ export default function RuleManagement({
|
||||
|
||||
{/* Drawer Scroll Body */}
|
||||
<form onSubmit={handleSaveForm} className="flex-1 overflow-y-auto p-6 space-y-5">
|
||||
{/* Rule ID Field (disabled if editing) */}
|
||||
{/* Rule ID is generated by the system and remains immutable. */}
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="rule-id" className="block text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||
事件 ID <span className="text-rose-500">*</span>
|
||||
事件 ID(系统生成)
|
||||
</label>
|
||||
<input
|
||||
id="rule-id"
|
||||
type="text"
|
||||
disabled={!!editingRule}
|
||||
placeholder="例如: customer-complaint-high"
|
||||
disabled
|
||||
value={formId}
|
||||
onChange={(e) => setFormId(e.target.value)}
|
||||
className={`w-full rounded border py-1.5 px-3 text-xs font-mono placeholder-slate-400 focus:outline-none transition ${
|
||||
editingRule
|
||||
? 'bg-slate-100 text-slate-400 border-slate-200 cursor-not-allowed'
|
||||
: formErrors.id
|
||||
? 'border-rose-300 focus:border-rose-500'
|
||||
: 'border-slate-200 focus:border-slate-900'
|
||||
}`}
|
||||
className="w-full cursor-not-allowed rounded border border-slate-200 bg-slate-100 py-1.5 px-3 font-mono text-xs text-slate-500"
|
||||
/>
|
||||
{formErrors.id ? (
|
||||
<p className="text-[10px] font-bold text-rose-500 flex items-center mt-1">
|
||||
@@ -563,11 +555,9 @@ export default function RuleManagement({
|
||||
{formErrors.id}
|
||||
</p>
|
||||
) : (
|
||||
!editingRule && (
|
||||
<p className="text-[10px] text-slate-400 mt-1">
|
||||
仅支持字母、数字、短横线与下划线,一经创建无法重置
|
||||
</p>
|
||||
)
|
||||
<p className="text-[10px] text-slate-400 mt-1">
|
||||
{editingRule ? '事件 ID 创建后保持不变' : '无需填写,系统已自动生成唯一 ID'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
import type { Rule } from './types';
|
||||
import { generateEventId } from './rule-id';
|
||||
|
||||
const SHEET_NAME = '监控规则';
|
||||
const HEADER_ROW = 3;
|
||||
const FIRST_DATA_ROW = 4;
|
||||
const TEMPLATE_ROWS = 50;
|
||||
|
||||
const HEADERS = ['事件 ID', '特征规则名称', '启用状态', '监控特征关键词'];
|
||||
const HEADERS = ['事件 ID(系统生成,可留空)', '特征规则名称', '启用状态', '监控特征关键词'];
|
||||
|
||||
function formatWorkbook(workbook: ExcelJS.Workbook, rules: Rule[]) {
|
||||
workbook.creator = 'ASR 事件监控中心';
|
||||
@@ -27,13 +28,13 @@ function formatWorkbook(workbook: ExcelJS.Workbook, rules: Rule[]) {
|
||||
|
||||
sheet.mergeCells('A2:D2');
|
||||
sheet.getCell('A2').value =
|
||||
'填写说明:每行一条规则;事件 ID 仅支持字母、数字、短横线和下划线;启用状态填写“启用”或“停用”;多个关键词请在同一单元格中换行填写。导入只更新页面草稿,仍需点击“保存并发布”。';
|
||||
'填写说明:每行一条规则;新增规则的事件 ID 请留空,导入时由系统自动生成;导出的已有 ID 请勿修改;启用状态填写“启用”或“停用”;多个关键词请在同一单元格中换行填写。导入后仍需点击“保存并发布”。';
|
||||
sheet.getCell('A2').style = {
|
||||
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFF7ED' } },
|
||||
font: { color: { argb: 'FF9A3412' }, size: 10 },
|
||||
alignment: { vertical: 'middle', wrapText: true },
|
||||
};
|
||||
sheet.getRow(2).height = 42;
|
||||
sheet.getRow(2).height = 50;
|
||||
|
||||
const header = sheet.getRow(HEADER_ROW);
|
||||
header.values = HEADERS;
|
||||
@@ -48,7 +49,7 @@ function formatWorkbook(workbook: ExcelJS.Workbook, rules: Rule[]) {
|
||||
});
|
||||
|
||||
sheet.columns = [
|
||||
{ key: 'id', width: 24 },
|
||||
{ key: 'id', width: 42 },
|
||||
{ key: 'name', width: 36 },
|
||||
{ key: 'enabled', width: 14 },
|
||||
{ key: 'keywords', width: 58 },
|
||||
@@ -71,7 +72,11 @@ function formatWorkbook(workbook: ExcelJS.Workbook, rules: Rule[]) {
|
||||
fill: {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: rowNumber % 2 === 0 ? 'FFFFFFFF' : 'FFF8FAFC' },
|
||||
fgColor: {
|
||||
argb: columnNumber === 1
|
||||
? 'FFF1F5F9'
|
||||
: rowNumber % 2 === 0 ? 'FFFFFFFF' : 'FFF8FAFC',
|
||||
},
|
||||
},
|
||||
font: { color: { argb: 'FF334155' }, size: 10 },
|
||||
alignment: {
|
||||
@@ -152,7 +157,7 @@ function findHeaderRow(sheet: ExcelJS.Worksheet) {
|
||||
const max = Math.min(sheet.actualRowCount, 10);
|
||||
for (let rowNumber = 1; rowNumber <= max; rowNumber += 1) {
|
||||
const row = sheet.getRow(rowNumber);
|
||||
if (cellText(row, 1) === HEADERS[0] && cellText(row, 2) === HEADERS[1]) {
|
||||
if (cellText(row, 1).startsWith('事件 ID') && cellText(row, 2) === HEADERS[1]) {
|
||||
return rowNumber;
|
||||
}
|
||||
}
|
||||
@@ -181,16 +186,16 @@ export async function importRulesFromExcel(file: File): Promise<Rule[]> {
|
||||
|
||||
for (let rowNumber = headerRow + 1; rowNumber <= sheet.actualRowCount; rowNumber += 1) {
|
||||
const row = sheet.getRow(rowNumber);
|
||||
const id = cellText(row, 1);
|
||||
const inputId = cellText(row, 1);
|
||||
const name = cellText(row, 2);
|
||||
const enabledText = cellText(row, 3);
|
||||
const keywordText = cellText(row, 4);
|
||||
if (![id, name, enabledText, keywordText].some(Boolean)) continue;
|
||||
if (![inputId, name, enabledText, keywordText].some(Boolean)) continue;
|
||||
|
||||
try {
|
||||
if (!id) throw new Error(`第 ${rowNumber} 行缺少事件 ID`);
|
||||
const id = inputId || generateEventId(ids);
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(id)) {
|
||||
throw new Error(`第 ${rowNumber} 行事件 ID 仅支持字母、数字、短横线和下划线`);
|
||||
throw new Error(`第 ${rowNumber} 行已有事件 ID 格式无效,请清空后由系统重新生成`);
|
||||
}
|
||||
if (ids.has(id)) throw new Error(`第 ${rowNumber} 行事件 ID 重复:${id}`);
|
||||
if (!name) throw new Error(`第 ${rowNumber} 行缺少特征规则名称`);
|
||||
|
||||
10
frontend/src/rule-id.ts
Normal file
10
frontend/src/rule-id.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export function generateEventId(existingIds: Iterable<string> = []): string {
|
||||
const existing = new Set(existingIds);
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
const randomPart = globalThis.crypto?.randomUUID?.()
|
||||
?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
|
||||
const id = `event-${randomPart}`;
|
||||
if (!existing.has(id)) return id;
|
||||
}
|
||||
throw new Error('无法生成唯一事件 ID,请重试');
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@@ -60,14 +61,12 @@ public class RuleValidator {
|
||||
if (rule == null) {
|
||||
throw new RuleValidationException("事件规则不能为空");
|
||||
}
|
||||
if (isBlank(rule.id())) {
|
||||
throw new RuleValidationException("事件 ID 不能为空");
|
||||
}
|
||||
if (!eventIds.add(rule.id())) {
|
||||
throw new RuleValidationException("事件 ID 重复: " + rule.id());
|
||||
String ruleId = isBlank(rule.id()) ? generateEventId(eventIds) : rule.id().trim();
|
||||
if (!eventIds.add(ruleId)) {
|
||||
throw new RuleValidationException("事件 ID 重复: " + ruleId);
|
||||
}
|
||||
if (isBlank(rule.name())) {
|
||||
throw new RuleValidationException("事件名称不能为空: " + rule.id());
|
||||
throw new RuleValidationException("事件名称不能为空: " + ruleId);
|
||||
}
|
||||
|
||||
List<String> cleanedKeywords = new ArrayList<>();
|
||||
@@ -81,20 +80,20 @@ public class RuleValidator {
|
||||
String normalized = TextNormalizer.normalize(display);
|
||||
if (normalized.isEmpty()) {
|
||||
throw new RuleValidationException(
|
||||
"标准化后关键词为空: " + display + "(事件 " + rule.id() + ")"
|
||||
"标准化后关键词为空: " + display + "(事件 " + ruleId + ")"
|
||||
);
|
||||
}
|
||||
if (!normalizedInRule.add(normalized)) {
|
||||
throw new RuleValidationException(
|
||||
"同一事件内关键词重复: " + display + "(事件 " + rule.id() + ")"
|
||||
"同一事件内关键词重复: " + display + "(事件 " + ruleId + ")"
|
||||
);
|
||||
}
|
||||
|
||||
String previousOwner = normalizedKeywordOwners.putIfAbsent(normalized, rule.id());
|
||||
if (previousOwner != null && !previousOwner.equals(rule.id())) {
|
||||
String previousOwner = normalizedKeywordOwners.putIfAbsent(normalized, ruleId);
|
||||
if (previousOwner != null && !previousOwner.equals(ruleId)) {
|
||||
throw new RuleValidationException(
|
||||
"同一关键词不能指向多个转接方向: \"" + display
|
||||
+ "\" 同时属于 " + previousOwner + " 与 " + rule.id()
|
||||
+ "\" 同时属于 " + previousOwner + " 与 " + ruleId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,12 +107,12 @@ public class RuleValidator {
|
||||
|
||||
if (rule.enabled() && cleanedKeywords.isEmpty()) {
|
||||
throw new RuleValidationException(
|
||||
"启用事件至少需要一个关键词: " + rule.id()
|
||||
"启用事件至少需要一个关键词: " + ruleId
|
||||
);
|
||||
}
|
||||
|
||||
normalizedRules.add(new EventRule(
|
||||
rule.id().trim(),
|
||||
ruleId,
|
||||
rule.name().trim(),
|
||||
rule.enabled(),
|
||||
cleanedKeywords
|
||||
@@ -136,6 +135,14 @@ public class RuleValidator {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private static String generateEventId(Set<String> existingIds) {
|
||||
String id;
|
||||
do {
|
||||
id = "event-" + UUID.randomUUID();
|
||||
} while (existingIds.contains(id));
|
||||
return id;
|
||||
}
|
||||
|
||||
public record ValidationResult(RuleDocument document, List<String> warnings) {
|
||||
public ValidationResult {
|
||||
warnings = warnings == null ? List.of() : List.copyOf(warnings);
|
||||
|
||||
@@ -61,4 +61,20 @@ class RuleValidatorTest {
|
||||
)));
|
||||
assertFalse(result.warnings().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generatesEventIdWhenMissing() {
|
||||
var result = validator.validateAndNormalize(new SaveRuleRequest(1L, List.of(
|
||||
new RuleSet(
|
||||
"transfer",
|
||||
"潜在转接",
|
||||
true,
|
||||
RuleSet.MATCHER_AC_KEYWORD,
|
||||
List.of(new EventRule(null, "自动编号规则", true, List.of("自动编号关键词")))
|
||||
)
|
||||
)));
|
||||
|
||||
String generatedId = result.document().ruleSets().getFirst().rules().getFirst().id();
|
||||
assertTrue(generatedId.startsWith("event-"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user