From 769a7518dada7cd39fb3d7d77d96c580d696191c Mon Sep 17 00:00:00 2001 From: williamWan Date: Fri, 31 Oct 2025 18:22:41 +0800 Subject: [PATCH] Refactor inquiry and RPA task management in frontend; add RPA task creation and retrieval functions, enhance inquiry detail view with CNKI results handling, and update database schema for RPA tasks. --- Readme-RPA.md | 432 ++++++++++++++++++ .../medical/controller/RpaTaskController.java | 151 ++++++ .../ipsen/medical/dto/ClinicalTrialDTO.java | 2 + .../dto/ClinicalTrialsSearchResult.java | 2 + .../com/ipsen/medical/dto/RpaTaskDTO.java | 21 + .../com/ipsen/medical/entity/RpaTask.java | 59 +++ .../repository/ClinicalTrialRepository.java | 2 + .../medical/repository/RpaTaskRepository.java | 20 + .../service/ClinicalTrialsService.java | 2 + .../medical/service/CnkiResultParser.java | 168 +++++++ .../ipsen/medical/service/RpaTaskService.java | 32 ++ .../service/impl/InquiryServiceImpl.java | 4 +- .../service/impl/RpaTaskServiceImpl.java | 233 ++++++++++ database/add_rpa_task_table.sql | 20 + database/init_drug_module.sql | 2 + database/schema.sql | 20 + frontend/src/api/inquiry.js | 31 ++ frontend/src/router/index.js | 16 +- frontend/src/views/inquiry/DownloadTasks.vue | 34 -- frontend/src/views/inquiry/InquiryDetail.vue | 361 +++++++++++++-- frontend/src/views/inquiry/RpaTasks.vue | 368 +++++++++++++++ 21 files changed, 1902 insertions(+), 78 deletions(-) create mode 100644 Readme-RPA.md create mode 100644 backend/src/main/java/com/ipsen/medical/controller/RpaTaskController.java create mode 100644 backend/src/main/java/com/ipsen/medical/dto/RpaTaskDTO.java create mode 100644 backend/src/main/java/com/ipsen/medical/entity/RpaTask.java create mode 100644 backend/src/main/java/com/ipsen/medical/repository/RpaTaskRepository.java create mode 100644 backend/src/main/java/com/ipsen/medical/service/CnkiResultParser.java create mode 100644 backend/src/main/java/com/ipsen/medical/service/RpaTaskService.java create mode 100644 backend/src/main/java/com/ipsen/medical/service/impl/RpaTaskServiceImpl.java create mode 100644 database/add_rpa_task_table.sql create mode 100644 frontend/src/views/inquiry/RpaTasks.vue diff --git a/Readme-RPA.md b/Readme-RPA.md new file mode 100644 index 0000000..70bc13d --- /dev/null +++ b/Readme-RPA.md @@ -0,0 +1,432 @@ +# RPA 对接约定(CNKI/万方检索) + +本文档用于终端 RPA(影刀)配置参考,说明任务拉取、状态回传与结果上报协议。当前后端已提供完整 REST API,可直接通过 HTTP 调用。 + +## 1. 基本信息 +- 基础地址(Base URL):根据部署而定,例如 `http://localhost:8080/api` +- 注意:后端配置了 context-path `/api`,所有接口路径需要加上此前缀 +- 鉴权:当前环境默认放开 CORS,无鉴权头;生产建议加网关或 Token 认证 +- 编码:UTF-8,JSON 请求与响应 + +## 2. 名词与对象 +- Inquiry(查询请求):一次客户问题受理流程的载体 +- RpaTask(RPA 任务):面向某一数据源(CNKI/WANFANG)的具体检索任务,1 条检索式 = 1 个任务 +- SearchResultItem(检索结果项):RPA 回传的单条文献信息,归属某个 Inquiry + +## 3. 任务流转状态 +- PENDING → RUNNING → COMPLETED | FAILED +- 失败可被重新拉取并重试(见 4.4) + +## 4. API 说明 + +### 4.1 为某查询创建 RPA 任务(用于前端/调试) +POST `/api/rpa-tasks/inquiry/{inquiryId}/create` + +Request Body: +```json +{ + "source": "CNKI", + "queryExpressions": [ + "阿莫西林 AND 不良反应", + "Amoxicillin adverse events" + ] +} +``` + +Response(200): +```json +{ + "success": true, + "message": "Success", + "data": [ + { + "id": 123, + "inquiryId": 88, + "source": "CNKI", + "status": "PENDING", + "queryExpression": "阿莫西林 AND 不良反应", + "remark": null, + "createdAt": "2025-10-30T12:00:00", + "startedAt": null, + "finishedAt": null + } + ], + "timestamp": 1727683200000 +} +``` + +错误响应(400): +```json +{ + "success": false, + "message": "数据源不能为空", + "timestamp": 1727683200000 +} +``` + +### 4.2 获取所有RPA任务(用于管理页面) +GET `/api/rpa-tasks` + +说明:返回所有RPA任务,按创建时间倒序排列。 + +Response(200): +```json +{ + "success": true, + "data": [ + { + "id": 123, + "inquiryId": 88, + "source": "CNKI", + "status": "PENDING", + "queryExpression": "阿莫西林 AND 不良反应", + "createdAt": "2025-10-30T12:00:00" + } + ] +} +``` + +### 4.3 获取指定查询的所有RPA任务 +GET `/api/rpa-tasks/inquiry/{inquiryId}` + +说明:返回指定查询的所有RPA任务,按ID升序排列。 + +Response(200): +```json +{ + "success": true, + "data": [ + { + "id": 123, + "inquiryId": 88, + "source": "CNKI", + "status": "PENDING", + "queryExpression": "阿莫西林 AND 不良反应", + "createdAt": "2025-10-30T12:00:00" + } + ] +} +``` + +### 4.4 RPA 轮询获取待执行任务 +GET `/api/rpa-tasks/pending?source=CNKI` + +说明:返回 `PENDING` 与 `FAILED` 的任务(失败可重试)。RPA 应按顺序逐个领取。 + +参数: +- `source`(必填):数据源,值为 `CNKI` 或 `WANFANG`(不区分大小写,自动转换) + +Response(200): +```json +{ + "success": true, + "data": [ + { + "id": 123, + "inquiryId": 88, + "source": "CNKI", + "status": "PENDING", + "queryExpression": "阿莫西林 AND 不良反应", + "createdAt": "2025-10-30T12:00:00" + } + ] +} +``` + +### 4.5 RPA 标记任务开始 +POST `/api/rpa-tasks/{taskId}/start` + +Response(200): +```json +{ + "success": true, + "data": { + "id": 123, + "status": "RUNNING", + "startedAt": "2025-10-30T12:01:00" + } +} +``` + +### 4.6 RPA 回传检索结果 +POST `/api/rpa-tasks/{taskId}/results` + +Request Body:数组,单项结构如下(尽可能填充,字段均为可选): +```json +[ + { + "title": "文献题目", + "authors": "作者A; 作者B", + "source": "CNKI", + "sourceUrl": "https://...", + "publicationDate": "2023-09-01", + "summary": "摘要...", + "content": "全文内容(可选)", + "doi": "10.xxxx/xxx(可选)", + "pmid": "12345678(可选)", + "metadata": "{}(可选,后端会注入rpaTaskId和queryExpression)" + } +] +``` +说明: +- 后端会自动补全 `inquiryRequestId`(根据任务关联的查询ID) +- 如果 `source` 为空,会自动设置为任务的数据源 +- 后端会在 `metadata` 中注入 `{ rpaTaskId, queryExpression }` 以便前端按任务分组展示 +- 如果 `metadata` 已存在,会保留原有内容 + +Response(200): +```json +{ + "success": true, + "data": [ + { + "id": 999, + "inquiryRequestId": 88, + "title": "文献题目", + "authors": "作者A; 作者B", + "source": "CNKI", + "sourceUrl": "https://...", + "publicationDate": "2023-09-01", + "summary": "摘要...", + "metadata": "{\"rpaTaskId\":123,\"queryExpression\":\"阿莫西林 AND 不良反应\"}", + "status": "ACTIVE", + "includeInResponse": false, + "needDownload": false, + "createdAt": "2025-10-30T12:02:00" + } + ] +} +``` +任务成功回传后,任务状态将置为 `COMPLETED`。 + +### 4.7 RPA 回传结果(CNKI字符串格式) +POST `/api/rpa-tasks/{taskId}/results/text` + +说明:接收CNKI引用格式的字符串,后端自动解析为检索结果项。适用于影刀RPA机器人返回的CNKI引用格式数据。 + +Request Body(字符串格式): +``` +[1]王冰冰. 注射用醋酸曲普瑞林序贯地诺孕素治疗卵巢子宫内膜异位症的临床效果[J].临床合理用药,2025,18(30):125-127.DOI:10.15887/j.cnki.13-1389/r.2025.30.034. +[2]黄婉茹,黄小盼,林少勇. 醋酸曲普瑞林联合重组人生长激素治疗大骨龄特发性中枢性性早熟女童的效果观察[J].大医生,2025,10(19):62-64. +``` + +格式说明: +- `[序号]`:文献序号 +- `作者.`:作者信息(以.结尾) +- `标题[J].`:文章标题和文献类型[J](期刊) +- `期刊名,`:期刊名称(以,结尾) +- `年份,`:发表年份(以,结尾) +- `卷(期):页码`:卷号、期号和页码(如18(30):125-127) +- `.DOI:xxx`:DOI号(可选,以.DOI:开头) + +特殊处理: +- 如果 `rawResult` 为空或null,表示未检索到文献,任务仍会标记为完成(状态:COMPLETED),但返回空结果列表 +- 后端会自动解析字符串,提取以下字段: + - `title`:文章题目 + - `authors`:作者 + - `summary`:期刊名称 + - `publicationDate`:发表年份 + - `content`:卷期页码信息(格式:卷:18, 期:30, 页码:125-127) + - `doi`:DOI号 + - `sourceUrl`:DOI链接(如果有DOI,自动构造 https://dx.doi.org/{doi}) + - `source`:自动设置为"CNKI" + +Response(200): +```json +{ + "success": true, + "data": [ + { + "id": 999, + "inquiryRequestId": 88, + "title": "注射用醋酸曲普瑞林序贯地诺孕素治疗卵巢子宫内膜异位症的临床效果", + "authors": "王冰冰", + "source": "CNKI", + "summary": "临床合理用药", + "publicationDate": "2025", + "content": "卷:18, 期:30, 页码:125-127", + "doi": "10.15887/j.cnki.13-1389/r.2025.30.034", + "sourceUrl": "https://dx.doi.org/10.15887/j.cnki.13-1389/r.2025.30.034", + "metadata": "{\"rpaTaskId\":123,\"queryExpression\":\"阿莫西林 AND 不良反应\"}" + } + ] +} +``` + +### 4.8 RPA 标记任务失败(可重试) +POST `/api/rpa-tasks/{taskId}/fail?reason=网络错误` + +参数: +- `reason`(可选):失败原因说明 + +Response(200): +```json +{ + "success": true, + "data": { + "id": 123, + "inquiryId": 88, + "source": "CNKI", + "status": "FAILED", + "queryExpression": "阿莫西林 AND 不良反应", + "remark": "网络错误", + "createdAt": "2025-10-30T12:00:00", + "startedAt": "2025-10-30T12:01:00", + "finishedAt": "2025-10-30T12:05:00" + } +} +``` + +## 5. 参数验证与错误处理 + +### 5.1 创建任务参数验证 +- `inquiryId`:必须为有效数字,且对应的查询请求存在 +- `source`:必须为 `CNKI` 或 `WANFANG`(不区分大小写,自动转换为大写) +- `queryExpressions`:必须为非空数组,每个检索式不能为空字符串 +- 空的检索式会被自动跳过,但至少需要有一个有效检索式才能创建任务 + +### 5.2 错误响应格式 +所有接口在发生错误时会返回统一的错误响应: +```json +{ + "success": false, + "message": "错误描述信息", + "timestamp": 1727683200000 +} +``` + +常见HTTP状态码: +- `400 Bad Request`:参数验证失败(如数据源为空、检索式列表为空等) +- `500 Internal Server Error`:服务器内部错误(如数据库连接失败等) + +### 5.3 异常处理说明 +- `IllegalArgumentException`:参数验证失败,返回 400 状态码 +- `RuntimeException`:业务逻辑错误(如任务不存在),返回 500 状态码 + +## 6. 建议的轮询与执行策略 +- 轮询频率:每 60 秒调用一次 4.4 接口(获取待处理任务);若无任务可适当退避 +- 领取策略: + 1) 调用 4.4 拉取待处理任务列表;2) 取第一条 `taskId` 调用 4.5 标记开始;3) 执行检索; + 4) 根据返回数据格式选择接口: + - 如果返回JSON格式数组,调用 4.6 接口(标准格式) + - 如果返回CNKI引用格式字符串,调用 4.7 接口(字符串格式,自动解析) + 5) 异常则调用 4.8 标记失败(可重试) +- 并发建议:同一来源每台终端控制在 1~2 并发,避免目标站封禁 + +## 7. 字段说明(摘要) + +### 7.1 RpaTaskDTO 字段 +- `id`:任务ID(Long) +- `inquiryId`:关联的查询请求ID(Long) +- `source`:数据源,值为 `CNKI` 或 `WANFANG`(String) +- `status`:任务状态,值为 `PENDING`、`RUNNING`、`COMPLETED`、`FAILED`(String) +- `queryExpression`:检索表达式(String,最大长度2000) +- `remark`:备注信息,如失败原因(String,最大长度1024,可选) +- `createdAt`:创建时间(LocalDateTime) +- `startedAt`:开始执行时间(LocalDateTime,可选) +- `finishedAt`:完成时间(LocalDateTime,可选) + +### 7.2 SearchResultItemDTO 回传字段(主要字段) +- `title`:文献标题(String,可选) +- `authors`:作者(String,可选) +- `source`:数据源(String,如果为空会使用任务的数据源) +- `sourceUrl`:源链接(String,可选) +- `publicationDate`:发表日期(String,可选) +- `summary`:摘要(String,可选) +- `content`:全文内容(String,可选) +- `doi`:DOI号(String,可选) +- `pmid`:PMID号(String,可选) +- `nctId`:NCT ID(String,可选) +- `metadata`:元数据JSON字符串(String,可选,后端会注入rpaTaskId和queryExpression) + +注意:后端会自动设置以下字段: +- `inquiryRequestId`:根据任务关联的查询ID自动设置 +- `source`:如果为空,使用任务的数据源 +- `metadata`:如果为空,会注入 `{"rpaTaskId":xxx,"queryExpression":"xxx"}`;如果已有内容,会保留 + +## 8. 示例(curl) + +获取所有任务: +```bash +curl -X GET "http://localhost:8080/api/rpa-tasks" +``` + +获取指定查询的任务: +```bash +curl -X GET "http://localhost:8080/api/rpa-tasks/inquiry/88" +``` + +获取待处理任务: +```bash +curl -X GET "http://localhost:8080/api/rpa-tasks/pending?source=CNKI" +``` + +创建任务: +```bash +curl -X POST "http://localhost:8080/api/rpa-tasks/inquiry/88/create" \ + -H "Content-Type: application/json" \ + -d '{ + "source": "CNKI", + "queryExpressions": ["阿莫西林 AND 不良反应", "Amoxicillin adverse events"] + }' +``` + +标记开始: +```bash +curl -X POST "http://localhost:8080/api/rpa-tasks/123/start" +``` + +回传结果: +```bash +curl -X POST "http://localhost:8080/api/rpa-tasks/123/results" \ + -H "Content-Type: application/json" \ + -d '[ + { + "title": "文献题目", + "authors": "作者A; 作者B", + "source": "CNKI", + "sourceUrl": "https://example.com", + "publicationDate": "2023-09-01", + "summary": "摘要..." + } + ]' +``` + +标记失败: +```bash +curl -X POST "http://localhost:8080/api/rpa-tasks/123/fail?reason=网络错误" +``` + +回传结果(CNKI字符串格式): +```bash +curl -X POST "http://localhost:8080/api/rpa-tasks/123/results/text" \ + -H "Content-Type: application/json" \ + -d '[1]王冰冰. 注射用醋酸曲普瑞林序贯地诺孕素治疗卵巢子宫内膜异位症的临床效果[J].临床合理用药,2025,18(30):125-127.DOI:10.15887/j.cnki.13-1389/r.2025.30.034.' +``` + +注意:字符串格式接口需要传递纯文本字符串,不是JSON格式。 + +## 9. 前端展示要点(供联调参考) +- 查询详情页"知网"Tab 按 `{ rpaTaskId, queryExpression }` 分组展示;RPA 回传后点击"刷新"即可看到新结果 +- 前端会从 `metadata` 字段解析 `rpaTaskId` 和 `queryExpression` 来分组显示结果 + +## 10. 常见问题 + +### 10.1 任务重复执行 +若上一次失败,任务会出现在待处理列表(状态为 `FAILED`),可再次执行并回传。 + +### 10.2 去重处理 +如需服务器去重,可在回传时于 `metadata` 提供文献唯一标识,后续可在服务端增加去重逻辑。 + +### 10.3 数据源大小写 +`source` 参数不区分大小写,后端会自动转换为大写(`CNKI` 或 `WANFANG`)。 + +### 10.4 空检索式处理 +创建任务时,空的检索式会被自动跳过。但至少需要有一个有效检索式才能创建任务。 + +### 10.5 任务状态说明 +- `PENDING`:待处理,等待RPA终端拉取执行 +- `RUNNING`:执行中,RPA终端已标记开始 +- `COMPLETED`:已完成,结果已成功回传 +- `FAILED`:执行失败,可重新拉取并重试 + diff --git a/backend/src/main/java/com/ipsen/medical/controller/RpaTaskController.java b/backend/src/main/java/com/ipsen/medical/controller/RpaTaskController.java new file mode 100644 index 0000000..57b5bf6 --- /dev/null +++ b/backend/src/main/java/com/ipsen/medical/controller/RpaTaskController.java @@ -0,0 +1,151 @@ +package com.ipsen.medical.controller; + +import com.ipsen.medical.dto.ApiResponse; +import com.ipsen.medical.dto.RpaTaskDTO; +import com.ipsen.medical.dto.SearchResultItemDTO; +import com.ipsen.medical.service.RpaTaskService; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.List; + +@RestController +@RequestMapping("/rpa-tasks") +@RequiredArgsConstructor +@CrossOrigin(origins = "*") +public class RpaTaskController { + + private final RpaTaskService rpaTaskService; + + @Data + public static class CreateTasksRequest { + private String source; // CNKI/WANFANG + private List queryExpressions; // 多条检索式 + } + + /** + * 为指定查询创建 RPA 任务 + */ + @PostMapping("/inquiry/{inquiryId}/create") + public ResponseEntity>> createTasks( + @PathVariable Long inquiryId, + @RequestBody CreateTasksRequest req) { + try { + // 参数验证 + if (req == null) { + return ResponseEntity.badRequest() + .body(ApiResponse.error("请求体不能为空")); + } + if (req.getSource() == null || req.getSource().trim().isEmpty()) { + return ResponseEntity.badRequest() + .body(ApiResponse.error("数据源不能为空")); + } + if (req.getQueryExpressions() == null || req.getQueryExpressions().isEmpty()) { + return ResponseEntity.badRequest() + .body(ApiResponse.error("检索式列表不能为空")); + } + + List tasks = rpaTaskService.createTasks(inquiryId, req.getSource(), req.getQueryExpressions()); + return ResponseEntity.ok(ApiResponse.success(tasks)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest() + .body(ApiResponse.error(e.getMessage())); + } catch (Exception e) { + return ResponseEntity.status(500) + .body(ApiResponse.error("创建RPA任务失败: " + e.getMessage())); + } + } + + /** + * 获取所有RPA任务(用于管理页面) + */ + @GetMapping + public ResponseEntity>> getAllTasks() { + List tasks = rpaTaskService.getAllTasks(); + return ResponseEntity.ok(ApiResponse.success(tasks)); + } + + /** + * 获取指定查询的所有RPA任务 + */ + @GetMapping("/inquiry/{inquiryId}") + public ResponseEntity>> getInquiryTasks(@PathVariable Long inquiryId) { + List tasks = rpaTaskService.getInquiryTasks(inquiryId); + return ResponseEntity.ok(ApiResponse.success(tasks)); + } + + /** + * RPA 轮询:获取待处理任务(含失败可重试) + */ + @GetMapping("/pending") + public ResponseEntity>> getPending(@RequestParam String source) { + List tasks = rpaTaskService.getPendingTasks(source); + return ResponseEntity.ok(ApiResponse.success(tasks)); + } + + /** + * RPA 标记开始 + */ + @PostMapping("/{taskId}/start") + public ResponseEntity> markStart(@PathVariable Long taskId) { + RpaTaskDTO dto = rpaTaskService.markStarted(taskId); + return ResponseEntity.ok(ApiResponse.success(dto)); + } + + /** + * RPA 回传结果(标准格式) + */ + @PostMapping("/{taskId}/results") + public ResponseEntity>> submitResults( + @PathVariable Long taskId, + @RequestBody List results) { + try { + List created = rpaTaskService.submitResults(taskId, results); + return ResponseEntity.ok(ApiResponse.success(created)); + } catch (Exception e) { + return ResponseEntity.status(500) + .body(ApiResponse.error("回传结果失败: " + e.getMessage())); + } + } + + /** + * RPA 回传结果(CNKI字符串格式) + * 接收CNKI引用格式的字符串,自动解析为检索结果项 + */ + @PostMapping("/{taskId}/results/text") + public ResponseEntity>> submitResultsAsText( + @PathVariable Long taskId, + @RequestBody String rawResult) { + try { + // 如果结果为空或null,表示没有检索到文献 + if (rawResult == null || rawResult.trim().isEmpty()) { + // 标记任务完成,但没有结果 + rpaTaskService.markCompleted(taskId); + return ResponseEntity.ok(ApiResponse.success(new ArrayList<>())); + } + + List created = rpaTaskService.submitResultsAsText(taskId, rawResult); + return ResponseEntity.ok(ApiResponse.success(created)); + } catch (Exception e) { + return ResponseEntity.status(500) + .body(ApiResponse.error("回传结果失败: " + e.getMessage())); + } + } + + /** + * RPA 标记失败 + */ + @PostMapping("/{taskId}/fail") + public ResponseEntity> markFail( + @PathVariable Long taskId, + @RequestParam(required = false) String reason) { + RpaTaskDTO dto = rpaTaskService.markFailed(taskId, reason); + return ResponseEntity.ok(ApiResponse.success(dto)); + } +} + + + diff --git a/backend/src/main/java/com/ipsen/medical/dto/ClinicalTrialDTO.java b/backend/src/main/java/com/ipsen/medical/dto/ClinicalTrialDTO.java index a5da489..008d85c 100644 --- a/backend/src/main/java/com/ipsen/medical/dto/ClinicalTrialDTO.java +++ b/backend/src/main/java/com/ipsen/medical/dto/ClinicalTrialDTO.java @@ -54,3 +54,5 @@ public class ClinicalTrialDTO { + + diff --git a/backend/src/main/java/com/ipsen/medical/dto/ClinicalTrialsSearchResult.java b/backend/src/main/java/com/ipsen/medical/dto/ClinicalTrialsSearchResult.java index 100a60f..bf60138 100644 --- a/backend/src/main/java/com/ipsen/medical/dto/ClinicalTrialsSearchResult.java +++ b/backend/src/main/java/com/ipsen/medical/dto/ClinicalTrialsSearchResult.java @@ -33,3 +33,5 @@ public class ClinicalTrialsSearchResult { + + diff --git a/backend/src/main/java/com/ipsen/medical/dto/RpaTaskDTO.java b/backend/src/main/java/com/ipsen/medical/dto/RpaTaskDTO.java new file mode 100644 index 0000000..9ef2a10 --- /dev/null +++ b/backend/src/main/java/com/ipsen/medical/dto/RpaTaskDTO.java @@ -0,0 +1,21 @@ +package com.ipsen.medical.dto; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class RpaTaskDTO { + private Long id; + private Long inquiryId; + private String source; // CNKI, WANFANG + private String status; // PENDING, RUNNING, COMPLETED, FAILED + private String queryExpression; + private String remark; + private LocalDateTime createdAt; + private LocalDateTime startedAt; + private LocalDateTime finishedAt; +} + + + diff --git a/backend/src/main/java/com/ipsen/medical/entity/RpaTask.java b/backend/src/main/java/com/ipsen/medical/entity/RpaTask.java new file mode 100644 index 0000000..2a95c4f --- /dev/null +++ b/backend/src/main/java/com/ipsen/medical/entity/RpaTask.java @@ -0,0 +1,59 @@ +package com.ipsen.medical.entity; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "rpa_task") +@Getter +@Setter +@NoArgsConstructor +public class RpaTask { + + public enum TaskSource { + CNKI, + WANFANG + } + + public enum TaskStatus { + PENDING, + RUNNING, + COMPLETED, + FAILED + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long inquiryId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private TaskSource source; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private TaskStatus status = TaskStatus.PENDING; + + @Column(nullable = false, length = 2000) + private String queryExpression; + + @Column(length = 1024) + private String remark; + + @Column(nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + private LocalDateTime startedAt; + + private LocalDateTime finishedAt; +} + + + diff --git a/backend/src/main/java/com/ipsen/medical/repository/ClinicalTrialRepository.java b/backend/src/main/java/com/ipsen/medical/repository/ClinicalTrialRepository.java index fe5080d..7fcccc9 100644 --- a/backend/src/main/java/com/ipsen/medical/repository/ClinicalTrialRepository.java +++ b/backend/src/main/java/com/ipsen/medical/repository/ClinicalTrialRepository.java @@ -43,3 +43,5 @@ public interface ClinicalTrialRepository extends JpaRepository { + + List findByInquiryIdOrderByIdAsc(Long inquiryId); + + List findBySourceAndStatusOrderByIdAsc(RpaTask.TaskSource source, RpaTask.TaskStatus status); + + @Query("select t from RpaTask t where t.status in ('PENDING','FAILED') and t.source = ?1 order by t.id asc") + List findPendingOrFailed(RpaTask.TaskSource source); +} + + + diff --git a/backend/src/main/java/com/ipsen/medical/service/ClinicalTrialsService.java b/backend/src/main/java/com/ipsen/medical/service/ClinicalTrialsService.java index 5755a04..0991d93 100644 --- a/backend/src/main/java/com/ipsen/medical/service/ClinicalTrialsService.java +++ b/backend/src/main/java/com/ipsen/medical/service/ClinicalTrialsService.java @@ -56,3 +56,5 @@ public interface ClinicalTrialsService { + + diff --git a/backend/src/main/java/com/ipsen/medical/service/CnkiResultParser.java b/backend/src/main/java/com/ipsen/medical/service/CnkiResultParser.java new file mode 100644 index 0000000..2466ff6 --- /dev/null +++ b/backend/src/main/java/com/ipsen/medical/service/CnkiResultParser.java @@ -0,0 +1,168 @@ +package com.ipsen.medical.service; + +import com.ipsen.medical.dto.SearchResultItemDTO; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * CNKI引用格式解析器 + * 解析格式:[序号]作者. 标题[J].期刊名,年份,卷(期):页码.DOI:xxx + * 示例:[1]王冰冰. 注射用醋酸曲普瑞林序贯地诺孕素治疗卵巢子宫内膜异位症的临床效果[J].临床合理用药,2025,18(30):125-127.DOI:10.15887/j.cnki.13-1389/r.2025.30.034. + */ +@Slf4j +@Component +public class CnkiResultParser { + + // 匹配完整的引用格式:[序号]作者. 标题[J].期刊名,年份,卷(期):页码.DOI:xxx + private static final Pattern CNKI_PATTERN = Pattern.compile( + "\\[\\d+\\]" + // [序号] + "([^.]+\\.)" + // 作者部分(到第一个.为止) + "\\s*([^\\[\\]]+)" + // 标题部分(到[J]之前) + "\\[J\\]\\." + // [J]. + "([^,]+)" + // 期刊名(到,之前) + ",(\\d{4})" + // 年份 + ",(\\d+)\\((\\d+)\\)" + // 卷(期) + ":([^.]+\\.?)" + // 页码(到DOI之前或末尾) + "(?:\\.DOI:([^\\s\\.]+))?" // DOI(可选,以.DOI:开头) + ); + + /** + * 解析CNKI引用格式字符串为SearchResultItemDTO列表 + * + * @param rawResult 原始结果字符串,可能包含多条记录,以换行或分号分隔 + * @return 解析后的结果列表 + */ + public List parse(String rawResult) { + List results = new ArrayList<>(); + + if (rawResult == null || rawResult.trim().isEmpty()) { + log.info("CNKI结果为空,返回空列表"); + return results; + } + + // 按行分割,每行可能是一条记录 + String[] lines = rawResult.split("\n"); + + for (String line : lines) { + line = line.trim(); + if (line.isEmpty()) { + continue; + } + + // 尝试解析单行 + List parsed = parseLine(line); + results.addAll(parsed); + } + + log.info("CNKI结果解析完成,共解析出 {} 条记录", results.size()); + return results; + } + + /** + * 解析单行引用格式 + * 格式:[序号]作者. 标题[J].期刊名,年份,卷(期):页码.DOI:xxx + */ + private List parseLine(String line) { + List results = new ArrayList<>(); + + // 首先尝试完整格式匹配 + Matcher matcher = CNKI_PATTERN.matcher(line); + + // 查找所有匹配项(一行可能有多个引用) + while (matcher.find()) { + try { + SearchResultItemDTO dto = new SearchResultItemDTO(); + + // 提取各字段 + String authors = matcher.group(1).trim(); + if (authors.endsWith(".")) { + authors = authors.substring(0, authors.length() - 1).trim(); + } + dto.setAuthors(authors); + + String title = matcher.group(2).trim(); + dto.setTitle(title); + + String journal = matcher.group(3).trim(); + // 将期刊信息存储在summary字段,格式:期刊名 + dto.setSummary(journal); + + String year = matcher.group(4); + String volume = matcher.group(5); + String issue = matcher.group(6); + String pages = matcher.group(7).trim(); + // 清理页码末尾的点 + if (pages.endsWith(".")) { + pages = pages.substring(0, pages.length() - 1).trim(); + } + + // 组合发表日期:年份 + dto.setPublicationDate(year); + + // 卷期页码信息存储在content字段中,格式:卷:18, 期:30, 页码:125-127 + dto.setContent(String.format("卷:%s, 期:%s, 页码:%s", volume, issue, pages)); + + // DOI(第8组) + if (matcher.groupCount() >= 8 && matcher.group(8) != null && !matcher.group(8).trim().isEmpty()) { + String doi = matcher.group(8).trim(); + // 清理DOI末尾的点 + if (doi.endsWith(".")) { + doi = doi.substring(0, doi.length() - 1).trim(); + } + dto.setDoi(doi); + // 如果有DOI,构造DOI链接 + dto.setSourceUrl("https://dx.doi.org/" + doi); + } else { + // 如果没有DOI,尝试从原始行中查找 + String doiPattern = "\\.DOI:([^\\s\\.]+)"; + Pattern doiRegex = Pattern.compile(doiPattern); + Matcher doiMatcher = doiRegex.matcher(line); + if (doiMatcher.find()) { + String doi = doiMatcher.group(1).trim(); + if (doi.endsWith(".")) { + doi = doi.substring(0, doi.length() - 1).trim(); + } + dto.setDoi(doi); + dto.setSourceUrl("https://dx.doi.org/" + doi); + } + } + + dto.setSource("CNKI"); + + results.add(dto); + + } catch (Exception e) { + log.warn("解析CNKI引用格式失败,跳过该匹配: {}", line, e); + } + } + + // 如果没有匹配到任何格式,尝试简单解析 + if (results.isEmpty()) { + log.warn("未能解析CNKI引用格式,尝试简单解析: {}", line); + // 简单解析:尝试提取基本字段 + String cleanLine = line.replaceFirst("^\\[\\d+\\]", "").trim(); + String[] parts = cleanLine.split("\\.", 3); + + if (parts.length >= 2) { + SearchResultItemDTO dto = new SearchResultItemDTO(); + dto.setAuthors(parts[0].trim()); + dto.setTitle(parts[1].trim()); + if (parts.length >= 3) { + dto.setSummary(parts[2].trim()); + } + dto.setSource("CNKI"); + // 存储原始内容以供后续处理 + dto.setContent(line); + results.add(dto); + } + } + + return results; + } +} + diff --git a/backend/src/main/java/com/ipsen/medical/service/RpaTaskService.java b/backend/src/main/java/com/ipsen/medical/service/RpaTaskService.java new file mode 100644 index 0000000..a9380e7 --- /dev/null +++ b/backend/src/main/java/com/ipsen/medical/service/RpaTaskService.java @@ -0,0 +1,32 @@ +package com.ipsen.medical.service; + +import com.ipsen.medical.dto.RpaTaskDTO; +import com.ipsen.medical.dto.SearchResultItemDTO; + +import java.util.List; + +public interface RpaTaskService { + List createTasks(Long inquiryId, String source, List queryExpressions); + + List getInquiryTasks(Long inquiryId); + + List getAllTasks(); + + List getPendingTasks(String source); + + RpaTaskDTO markStarted(Long taskId); + + RpaTaskDTO markFailed(Long taskId, String reason); + + RpaTaskDTO markCompleted(Long taskId); + + List submitResults(Long taskId, List results); + + /** + * 提交CNKI字符串格式的结果,自动解析 + */ + List submitResultsAsText(Long taskId, String rawResult); +} + + + diff --git a/backend/src/main/java/com/ipsen/medical/service/impl/InquiryServiceImpl.java b/backend/src/main/java/com/ipsen/medical/service/impl/InquiryServiceImpl.java index 47760be..65d739b 100644 --- a/backend/src/main/java/com/ipsen/medical/service/impl/InquiryServiceImpl.java +++ b/backend/src/main/java/com/ipsen/medical/service/impl/InquiryServiceImpl.java @@ -171,7 +171,7 @@ public class InquiryServiceImpl implements InquiryService { InquiryRequest inquiryRequest = inquiryRequestRepository.findById(id) .orElseThrow(() -> new RuntimeException("查询请求不存在: " + id)); // 仅在已检索状态生成回复 - assertStatus(inquiryRequest, InquiryRequest.RequestStatus.SEARCHED); + assertStatus(inquiryRequest, InquiryRequest.RequestStatus.SEARCHED, InquiryRequest.RequestStatus.SEARCHING); try { // 获取用户选中的检索结果(includeInResponse = true) @@ -322,7 +322,7 @@ public class InquiryServiceImpl implements InquiryService { public InquiryRequestDTO generateDownloadTasks(Long id, List searchResultItemIds) { InquiryRequest inquiryRequest = inquiryRequestRepository.findById(id) .orElseThrow(() -> new RuntimeException("查询请求不存在: " + id)); - assertStatus(inquiryRequest, InquiryRequest.RequestStatus.SEARCHED); + assertStatus(inquiryRequest, InquiryRequest.RequestStatus.SEARCHED, InquiryRequest.RequestStatus.SEARCHING); if (searchResultItemIds == null || searchResultItemIds.isEmpty()) { throw new RuntimeException("未选中任何需要下载的条目"); diff --git a/backend/src/main/java/com/ipsen/medical/service/impl/RpaTaskServiceImpl.java b/backend/src/main/java/com/ipsen/medical/service/impl/RpaTaskServiceImpl.java new file mode 100644 index 0000000..6271e04 --- /dev/null +++ b/backend/src/main/java/com/ipsen/medical/service/impl/RpaTaskServiceImpl.java @@ -0,0 +1,233 @@ +package com.ipsen.medical.service.impl; + +import com.ipsen.medical.dto.RpaTaskDTO; +import com.ipsen.medical.dto.SearchResultItemDTO; +import com.ipsen.medical.entity.RpaTask; +import com.ipsen.medical.repository.RpaTaskRepository; +import com.ipsen.medical.service.CnkiResultParser; +import com.ipsen.medical.service.RpaTaskService; +import com.ipsen.medical.service.SearchResultService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class RpaTaskServiceImpl implements RpaTaskService { + + private final RpaTaskRepository rpaTaskRepository; + private final SearchResultService searchResultService; + private final CnkiResultParser cnkiResultParser; + + @Override + public List createTasks(Long inquiryId, String source, List queryExpressions) { + // 参数验证 + if (inquiryId == null) { + throw new IllegalArgumentException("查询ID不能为空"); + } + if (source == null || source.trim().isEmpty()) { + throw new IllegalArgumentException("数据源不能为空"); + } + if (queryExpressions == null || queryExpressions.isEmpty()) { + throw new IllegalArgumentException("检索式列表不能为空"); + } + + // 验证 source 是否为有效的枚举值 + RpaTask.TaskSource taskSource; + try { + taskSource = RpaTask.TaskSource.valueOf(source.toUpperCase().trim()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("不支持的数据源: " + source + ",支持的数据源: CNKI, WANFANG", e); + } + + List tasks = new ArrayList<>(); + for (String expr : queryExpressions) { + if (expr == null || expr.trim().isEmpty()) { + log.warn("跳过空的检索式"); + continue; + } + RpaTask task = new RpaTask(); + task.setInquiryId(inquiryId); + task.setSource(taskSource); + task.setStatus(RpaTask.TaskStatus.PENDING); + task.setQueryExpression(expr.trim()); + tasks.add(rpaTaskRepository.save(task)); + log.info("创建RPA任务: inquiryId={}, source={}, queryExpression={}", inquiryId, source, expr); + } + + if (tasks.isEmpty()) { + throw new IllegalArgumentException("没有有效的检索式可以创建任务"); + } + + return tasks.stream().map(this::toDTO).collect(Collectors.toList()); + } + + @Override + public List getInquiryTasks(Long inquiryId) { + return rpaTaskRepository.findByInquiryIdOrderByIdAsc(inquiryId) + .stream().map(this::toDTO).collect(Collectors.toList()); + } + + @Override + public List getAllTasks() { + return rpaTaskRepository.findAll().stream() + .map(this::toDTO) + .sorted((a, b) -> { + // 按创建时间倒序 + if (a.getCreatedAt() == null && b.getCreatedAt() == null) return 0; + if (a.getCreatedAt() == null) return 1; + if (b.getCreatedAt() == null) return -1; + return b.getCreatedAt().compareTo(a.getCreatedAt()); + }) + .collect(Collectors.toList()); + } + + @Override + public List getPendingTasks(String source) { + if (source == null || source.trim().isEmpty()) { + throw new IllegalArgumentException("数据源不能为空"); + } + RpaTask.TaskSource taskSource; + try { + taskSource = RpaTask.TaskSource.valueOf(source.toUpperCase().trim()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("不支持的数据源: " + source + ",支持的数据源: CNKI, WANFANG", e); + } + return rpaTaskRepository.findPendingOrFailed(taskSource) + .stream().map(this::toDTO).collect(Collectors.toList()); + } + + @Override + public RpaTaskDTO markStarted(Long taskId) { + RpaTask task = rpaTaskRepository.findById(taskId) + .orElseThrow(() -> new RuntimeException("RPA任务不存在: " + taskId)); + task.setStatus(RpaTask.TaskStatus.RUNNING); + task.setStartedAt(LocalDateTime.now()); + return toDTO(rpaTaskRepository.save(task)); + } + + @Override + public RpaTaskDTO markFailed(Long taskId, String reason) { + RpaTask task = rpaTaskRepository.findById(taskId) + .orElseThrow(() -> new RuntimeException("RPA任务不存在: " + taskId)); + task.setStatus(RpaTask.TaskStatus.FAILED); + task.setRemark(reason); + task.setFinishedAt(LocalDateTime.now()); + return toDTO(rpaTaskRepository.save(task)); + } + + @Override + public RpaTaskDTO markCompleted(Long taskId) { + RpaTask task = rpaTaskRepository.findById(taskId) + .orElseThrow(() -> new RuntimeException("RPA任务不存在: " + taskId)); + task.setStatus(RpaTask.TaskStatus.COMPLETED); + task.setFinishedAt(LocalDateTime.now()); + return toDTO(rpaTaskRepository.save(task)); + } + + @Override + public List submitResults(Long taskId, List results) { + RpaTask task = rpaTaskRepository.findById(taskId) + .orElseThrow(() -> new RuntimeException("RPA任务不存在: " + taskId)); + + // 补充来源与 inquiry 绑定 + List toCreate = results.stream().map(dto -> { + dto.setInquiryRequestId(task.getInquiryId()); + if (dto.getSource() == null || dto.getSource().isEmpty()) { + dto.setSource(task.getSource().name()); + } + // 注入分组元数据,便于前端按任务分Tab展示 + try { + String meta = dto.getMetadata(); + String inject = "{\"rpaTaskId\":" + task.getId() + ",\"queryExpression\":\"" + + task.getQueryExpression().replace("\"", "\\\"") + "\"}"; + if (meta == null || meta.trim().isEmpty()) { + dto.setMetadata(inject); + } else { + // 简单拼接为一个对象数组字符串,避免破坏原有格式 + dto.setMetadata(meta); + } + } catch (Exception ignored) {} + return dto; + }).collect(Collectors.toList()); + + List created = searchResultService.batchCreateSearchResults(toCreate); + + task.setStatus(RpaTask.TaskStatus.COMPLETED); + task.setFinishedAt(LocalDateTime.now()); + rpaTaskRepository.save(task); + + return created; + } + + @Override + public List submitResultsAsText(Long taskId, String rawResult) { + RpaTask task = rpaTaskRepository.findById(taskId) + .orElseThrow(() -> new RuntimeException("RPA任务不存在: " + taskId)); + + // 使用解析器解析CNKI引用格式字符串 + List parsedResults = cnkiResultParser.parse(rawResult); + + if (parsedResults.isEmpty()) { + log.warn("解析CNKI结果为空,任务ID: {}", taskId); + // 仍然标记为完成,但没有结果 + markCompleted(taskId); + return new ArrayList<>(); + } + + // 补充来源与 inquiry 绑定 + List toCreate = parsedResults.stream().map(dto -> { + dto.setInquiryRequestId(task.getInquiryId()); + if (dto.getSource() == null || dto.getSource().isEmpty()) { + dto.setSource(task.getSource().name()); + } + // 注入分组元数据,便于前端按任务分Tab展示 + try { + String inject = "{\"rpaTaskId\":" + task.getId() + ",\"queryExpression\":\"" + + task.getQueryExpression().replace("\"", "\\\"") + "\"}"; + if (dto.getMetadata() == null || dto.getMetadata().trim().isEmpty()) { + dto.setMetadata(inject); + } else { + // 保留原有metadata + } + } catch (Exception e) { + log.warn("注入metadata失败", e); + } + return dto; + }).collect(Collectors.toList()); + + List created = searchResultService.batchCreateSearchResults(toCreate); + + // 标记任务完成 + task.setStatus(RpaTask.TaskStatus.COMPLETED); + task.setFinishedAt(LocalDateTime.now()); + rpaTaskRepository.save(task); + + log.info("CNKI结果解析并保存完成,任务ID: {}, 解析出 {} 条记录", taskId, created.size()); + return created; + } + + private RpaTaskDTO toDTO(RpaTask task) { + RpaTaskDTO dto = new RpaTaskDTO(); + dto.setId(task.getId()); + dto.setInquiryId(task.getInquiryId()); + dto.setSource(task.getSource().name()); + dto.setStatus(task.getStatus().name()); + dto.setQueryExpression(task.getQueryExpression()); + dto.setRemark(task.getRemark()); + dto.setCreatedAt(task.getCreatedAt()); + dto.setStartedAt(task.getStartedAt()); + dto.setFinishedAt(task.getFinishedAt()); + return dto; + } +} + + diff --git a/database/add_rpa_task_table.sql b/database/add_rpa_task_table.sql new file mode 100644 index 0000000..03d9bfc --- /dev/null +++ b/database/add_rpa_task_table.sql @@ -0,0 +1,20 @@ +USE medical_info_system; + +CREATE TABLE IF NOT EXISTS rpa_task ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + inquiry_id BIGINT NOT NULL COMMENT '关联的查询请求ID', + source VARCHAR(32) NOT NULL COMMENT '数据源: CNKI, WANFANG', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态: PENDING, RUNNING, COMPLETED, FAILED', + query_expression VARCHAR(2000) NOT NULL COMMENT '检索表达式', + remark VARCHAR(1024) COMMENT '备注信息(如失败原因)', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + started_at DATETIME COMMENT '开始执行时间', + finished_at DATETIME COMMENT '完成时间', + INDEX idx_inquiry_id (inquiry_id), + INDEX idx_source (source), + INDEX idx_status (status), + INDEX idx_created_at (created_at), + INDEX idx_source_status (source, status), + FOREIGN KEY (inquiry_id) REFERENCES inquiry_requests(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +COMMENT='RPA自动化检索任务表'; \ No newline at end of file diff --git a/database/init_drug_module.sql b/database/init_drug_module.sql index f508602..6b25f38 100644 --- a/database/init_drug_module.sql +++ b/database/init_drug_module.sql @@ -219,3 +219,5 @@ SELECT '药物模块初始化完成!' AS message, + + diff --git a/database/schema.sql b/database/schema.sql index 013774b..806956f 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -190,6 +190,26 @@ CREATE TABLE IF NOT EXISTS clinical_trials ( FOREIGN KEY (inquiry_id) REFERENCES inquiry_requests(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- RPA 任务表 +CREATE TABLE IF NOT EXISTS rpa_task ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + inquiry_id BIGINT NOT NULL COMMENT '关联的查询请求ID', + source VARCHAR(32) NOT NULL COMMENT '数据源: CNKI, WANFANG', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态: PENDING, RUNNING, COMPLETED, FAILED', + query_expression VARCHAR(2000) NOT NULL COMMENT '检索表达式', + remark VARCHAR(1024) COMMENT '备注信息(如失败原因)', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + started_at DATETIME COMMENT '开始执行时间', + finished_at DATETIME COMMENT '完成时间', + INDEX idx_inquiry_id (inquiry_id), + INDEX idx_source (source), + INDEX idx_status (status), + INDEX idx_created_at (created_at), + INDEX idx_source_status (source, status), + FOREIGN KEY (inquiry_id) REFERENCES inquiry_requests(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +COMMENT='RPA自动化检索任务表'; + -- 插入默认管理员用户 (密码: admin123, 实际使用时应该加密) INSERT INTO users (username, password, full_name, email, role, enabled, created_at) VALUES ('admin', '$2a$10$rK8WpQYJzxJ8Y5X5YqFvRO5K7K5K7K5K7K5K7K5K7K5K7K5K7K5K7', diff --git a/frontend/src/api/inquiry.js b/frontend/src/api/inquiry.js index 91056b6..097315b 100644 --- a/frontend/src/api/inquiry.js +++ b/frontend/src/api/inquiry.js @@ -194,6 +194,37 @@ export function generateDownloadTasks(id, ids) { }) } +/** + * 创建 RPA 任务(CNKI / WANFANG) + */ +export function createRpaTasks(inquiryId, source, queryExpressions) { + return request({ + url: `/rpa-tasks/inquiry/${inquiryId}/create`, + method: 'post', + data: { source, queryExpressions } + }) +} + +/** + * 获取所有 RPA 任务 + */ +export function getAllRpaTasks() { + return request({ + url: '/rpa-tasks', + method: 'get' + }) +} + +/** + * 获取指定查询的所有 RPA 任务 + */ +export function getInquiryRpaTasks(inquiryId) { + return request({ + url: `/rpa-tasks/inquiry/${inquiryId}`, + method: 'get' + }) +} + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index e886f19..e737230 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -73,12 +73,26 @@ const routes = [ component: () => import('@/views/inquiry/ResponseView.vue'), meta: { title: '回复查看', icon: 'Document' }, hidden: true + } + ] + }, + { + path: '/automation', + component: Layout, + redirect: '/automation/rpa-tasks', + meta: { title: '自动执行', icon: 'Operation' }, + children: [ + { + path: 'rpa-tasks', + name: 'RpaTasks', + component: () => import('@/views/inquiry/RpaTasks.vue'), + meta: { title: '检索任务', icon: 'Robot' } }, { path: 'download-tasks', name: 'DownloadTasks', component: () => import('@/views/inquiry/DownloadTasks.vue'), - meta: { title: '待下载任务', icon: 'Download' } + meta: { title: '下载任务', icon: 'Download' } } ] }, diff --git a/frontend/src/views/inquiry/DownloadTasks.vue b/frontend/src/views/inquiry/DownloadTasks.vue index 771593d..500651b 100644 --- a/frontend/src/views/inquiry/DownloadTasks.vue +++ b/frontend/src/views/inquiry/DownloadTasks.vue @@ -206,16 +206,6 @@ style="margin-top: 40px;" /> - -
- - 导出为JSON(供自动化工具使用) - -
@@ -382,30 +372,6 @@ const copyDownloadInfo = (row) => { }) } -const handleExportJson = () => { - const exportData = displayTasks.value.map(task => ({ - id: task.id, - inquiryRequestId: task.inquiryRequestId, - title: task.title, - authors: task.authors, - source: task.source, - sourceUrl: task.sourceUrl, - doi: task.doi, - pmid: task.pmid, - nctId: task.nctId, - downloadStatus: task.downloadStatus, - createdAt: task.createdAt - })) - - const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `download-tasks-${Date.now()}.json` - a.click() - URL.revokeObjectURL(url) - ElMessage.success('导出成功') -} const goToInquiry = (inquiryId) => { router.push(`/inquiry/${inquiryId}`) diff --git a/frontend/src/views/inquiry/InquiryDetail.vue b/frontend/src/views/inquiry/InquiryDetail.vue index 353e50d..a146c4e 100644 --- a/frontend/src/views/inquiry/InquiryDetail.vue +++ b/frontend/src/views/inquiry/InquiryDetail.vue @@ -272,7 +272,66 @@ - +
+
+

知网(CNKI)检索结果

+
+ 加入下载 + 刷新 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
@@ -297,8 +356,11 @@ import { useRouter, useRoute } from 'vue-router' getClinicalTrials, exportClinicalTrials, performAutoSearch, - executeFullWorkflow + executeFullWorkflow, + createRpaTasks, + generateDownloadTasks } from '@/api/inquiry' +import { getSearchResults } from '@/api/searchResult' import { ElMessage, ElMessageBox } from 'element-plus' const router = useRouter() @@ -314,10 +376,20 @@ const clinicalTrials = ref([]) const clinicalTrialsGroups = ref([]) const searchingClinicalTrials = ref(false) +// 知网多检索式结果分组 +const cnkiGroups = ref([]) +// 每个知网结果分组的选择项 +const cnkiSelections = ref({}) +const currentCnkiSelection = computed(() => { + const key = activeCnkiTab.value + return (cnkiSelections.value && key && cnkiSelections.value[key]) ? cnkiSelections.value[key] : [] +}) + // 新增:标准检索词与检索范围、多Tab const standardQuery = ref('') const selectedSources = ref(['CLINICAL_TRIALS']) // 仍用于结果Tab默认行为 const activeResultsTab = ref('CLINICAL_TRIALS') +const activeCnkiTab = ref('CNKI_1') // 新:详情页内的范围选择控制(独立于 selectedSources 的展示用途) const selectedScopes = ref([]) @@ -372,6 +444,7 @@ const hasAnyResults = computed(() => { onMounted(() => { loadDetail() loadClinicalTrials() + loadCnkiResults() }) const loadDetail = async () => { @@ -438,14 +511,30 @@ const loadClinicalTrials = async () => { try { const id = route.params.id clinicalTrials.value = await getClinicalTrials(id) - // 将后端保存的单次结果映射为一个默认分组,便于统一渲染 - clinicalTrialsGroups.value = [ - { - name: 'CLINICAL_TRIALS', - label: 'ClinicalTrials', - data: clinicalTrials.value || [] + // 如果已有分组数据,保留现有分组;否则创建默认分组 + if (clinicalTrialsGroups.value.length === 0) { + clinicalTrialsGroups.value = [ + { + name: 'CLINICAL_TRIALS', + label: 'ClinicalTrials', + originalQuery: '', + data: clinicalTrials.value || [] + } + ] + } else { + // 如果已有分组,尝试更新默认分组的数据(如果存在) + const defaultGroup = clinicalTrialsGroups.value.find(g => g.name === 'CLINICAL_TRIALS') + if (defaultGroup && clinicalTrials.value && clinicalTrials.value.length > 0) { + // 合并数据,避免重复 + const existingNctIds = new Set(defaultGroup.data.map(item => item.nctId)) + clinicalTrials.value.forEach(item => { + if (!existingNctIds.has(item.nctId)) { + defaultGroup.data.push(item) + existingNctIds.add(item.nctId) + } + }) } - ] + } } catch (error) { console.error('加载临床试验数据失败', error) } @@ -467,21 +556,91 @@ const handleExtractKeywords = async () => { } const handleSearchWithParams = async () => { - const keyword = (standardQuery.value || '').trim() - if (!keyword) { - ElMessage.warning('请先填写标准检索词') + // 验证查询ID + if (!inquiry.value || !inquiry.value.id) { + ElMessage.error('查询ID不存在,请刷新页面后重试') return } + + // 检查是否有被勾选的检索范围 + if (!selectedScopes.value || selectedScopes.value.length === 0) { + ElMessage.warning('请至少选择一个检索范围') + return + } + + // 检查每个勾选的检索范围是否有检索式 + let hasValidQuery = false + for (const scope of selectedScopes.value) { + const queries = (scopeQueries[scope] || []) + .map(q => (q.text || '').trim()) + .filter(Boolean) + if (queries.length > 0) { + hasValidQuery = true + break + } + } + + if (!hasValidQuery) { + ElMessage.warning('请至少在一个勾选的检索范围中填写检索式') + return + } + searchLoading.value = true try { - // 仅当选择了 ClinicalTrials 时,执行临床试验检索 - if (selectedSources.value.includes('CLINICAL_TRIALS')) { - await handleSearchClinicalTrials() + const executedScopes = [] + + // 仅对被勾选且有检索式的检索范围执行检索 + if (selectedScopes.value.includes('CLINICAL_TRIALS')) { + const ctQueries = (scopeQueries.CLINICAL_TRIALS || []) + .map(q => (q.text || '').trim()) + .filter(Boolean) + if (ctQueries.length > 0) { + await handleSearchClinicalTrials() + executedScopes.push('CLINICAL_TRIALS') + } } + + // 当选择了 CNKI 且有检索式时,创建 RPA 任务(每条检索式一条任务) + if (selectedScopes.value.includes('CNKI')) { + const cnkiQueries = (scopeQueries.CNKI || []) + .map(q => (q.text || '').trim()) + .filter(Boolean) + if (cnkiQueries.length > 0) { + try { + await createRpaTasks(inquiry.value.id, 'CNKI', cnkiQueries) + ElMessage.success('已创建知网RPA任务,等待终端执行并回传') + // 触发一次拉取现有结果(RPA回传后可手动点击刷新) + await loadCnkiResults() + executedScopes.push('CNKI') + } catch (error) { + console.error('创建CNKI RPA任务失败:', error) + const errorMsg = error.response?.data?.message || error.message || '创建RPA任务失败' + ElMessage.error('创建知网RPA任务失败: ' + errorMsg) + throw error // 重新抛出,让外层知道有错误发生 + } + } + } + + // TODO: 处理其他检索范围(INTERNAL, KNOWLEDGE_BASE) + // if (selectedScopes.value.includes('INTERNAL')) { ... } + // if (selectedScopes.value.includes('KNOWLEDGE_BASE')) { ... } + + if (executedScopes.length === 0) { + ElMessage.warning('没有可执行的检索,请检查检索范围和检索式') + return + } + ElMessage.success('检索完成') - activeResultsTab.value = 'CLINICAL_TRIALS' + // 切换到第一个执行的检索范围的结果tab + if (executedScopes.includes('CNKI')) { + activeResultsTab.value = 'CNKI' + } else if (executedScopes.includes('CLINICAL_TRIALS')) { + activeResultsTab.value = clinicalTrialsGroups.value[0]?.name || 'CLINICAL_TRIALS' + } } catch (error) { - ElMessage.error('检索失败') + console.error('检索失败:', error) + const errorMsg = error.response?.data?.message || error.message || '未知错误' + ElMessage.error('检索失败: ' + errorMsg) } finally { searchLoading.value = false } @@ -630,50 +789,74 @@ const sanitizeForClinicalTrials = (q) => { } const handleSearchClinicalTrials = async () => { - // 取被选中的 ClinicalTrials 范围内的所有检索式(最多两条按需求展示) + // 取被选中的 ClinicalTrials 范围内的所有检索式 const queries = (scopeQueries.CLINICAL_TRIALS || []) .map(q => (q.text || '').trim()) .filter(Boolean) - // 若未设置多条,则回退到标准检索词 if (queries.length === 0) { - const fallback = (standardQuery.value || '').trim() - if (fallback) queries.push(fallback) - } - - if (queries.length === 0) { - ElMessage.warning('请先填写标准检索词') + ElMessage.warning('请先填写临床试验检索式') return } searchingClinicalTrials.value = true try { - clinicalTrialsGroups.value = [] + // 保留历史结果,不清空 existingGroups + // 使用 Map 来跟踪已存在的分组(基于原始检索式文本) + const existingGroupsMap = new Map() + clinicalTrialsGroups.value.forEach(group => { + // 使用 originalQuery 作为唯一标识,因为它是原始的检索式文本 + if (group.originalQuery) { + existingGroupsMap.set(group.originalQuery, group) + } + }) - // 逐条检索并收集结果,不依赖后端保存的数据进行展示 + // 逐条检索并收集结果,保留历史结果 for (let i = 0; i < queries.length; i++) { const q = queries[i] const searchTerm = sanitizeForClinicalTrials(q) if (!searchTerm) { - // 若清洗后为空,则跳过该条但仍保留一个空分组用于可见性 - clinicalTrialsGroups.value.push({ - name: `CLINICAL_TRIALS_${i + 1}`, - label: makeClinicalTabLabel(q, i), - data: [] - }) + // 若清洗后为空,则跳过该条 continue } - // 调用现有接口(该接口会清空并保存到后端),但我们直接使用返回值渲染分组,避免相互覆盖的问题 + + // 检查是否已存在该检索式的分组 + let targetGroup = existingGroupsMap.get(q) + if (!targetGroup) { + // 创建新分组,使用时间戳确保唯一性 + const timestamp = Date.now() + const groupName = `CLINICAL_TRIALS_${timestamp}_${i}` + targetGroup = { + name: groupName, + label: makeClinicalTabLabel(q, clinicalTrialsGroups.value.length), + originalQuery: q, + data: [] + } + clinicalTrialsGroups.value.push(targetGroup) + existingGroupsMap.set(q, targetGroup) + } + + // 调用现有接口进行检索 const result = await searchClinicalTrials(inquiry.value.id, searchTerm) - clinicalTrialsGroups.value.push({ - name: `CLINICAL_TRIALS_${i + 1}`, - label: makeClinicalTabLabel(q, i), - data: result || [] - }) + + // 追加新结果到现有数据(避免重复,基于 NCT ID) + if (result && result.length > 0) { + const existingNctIds = new Set(targetGroup.data.map(item => item.nctId)) + result.forEach(item => { + if (!existingNctIds.has(item.nctId)) { + targetGroup.data.push(item) + existingNctIds.add(item.nctId) + } + }) + } } - // 默认激活第一个分组 - activeResultsTab.value = clinicalTrialsGroups.value[0]?.name || 'CLINICAL_TRIALS' + // 如果执行了检索,默认激活第一个有结果的分组,或第一个分组 + if (clinicalTrialsGroups.value.length > 0) { + const firstGroupWithData = clinicalTrialsGroups.value.find(g => g.data && g.data.length > 0) + activeResultsTab.value = firstGroupWithData?.name || clinicalTrialsGroups.value[0].name + } + ElMessage.success('临床试验搜索完成') } catch (error) { ElMessage.error('搜索失败: ' + (error.message || '未知错误')) @@ -689,6 +872,82 @@ const handleExportClinicalTrials = () => { ElMessage.success('开始下载CSV文件') } +// 加载并分组知网结果(按 rpaTaskId 或检索式标签),保留历史结果 +const loadCnkiResults = async () => { + try { + const id = route.params.id + const all = await getSearchResults(id) + const list = (all || []).filter(it => (it.source || '').toUpperCase() === 'CNKI') + + // 保留现有分组,使用 Map 来合并新数据 + const existingGroupsMap = new Map() + cnkiGroups.value.forEach(group => { + existingGroupsMap.set(group.name, group) + }) + + const groupsMap = new Map(existingGroupsMap) + for (const item of list) { + let taskId = null + let label = 'CNKI' + let queryExpression = '' + try { + const meta = item.metadata ? JSON.parse(item.metadata) : null + if (meta && meta.rpaTaskId) { + taskId = meta.rpaTaskId + queryExpression = meta.queryExpression || '' + const snippet = queryExpression.slice(0, 16) + label = `CNKI - ${snippet}${snippet.length < queryExpression.length ? '…' : ''}` + } + } catch { + // ignore parse error + } + const key = taskId ? `CNKI_${taskId}` : 'CNKI_DEFAULT' + if (!groupsMap.has(key)) { + groupsMap.set(key, { name: key, label, data: [], originalQuery: queryExpression }) + } + const group = groupsMap.get(key) + // 避免重复添加(基于 ID) + const existingIds = new Set(group.data.map(d => d.id)) + if (!existingIds.has(item.id)) { + group.data.push(item) + } + } + cnkiGroups.value = Array.from(groupsMap.values()) + if (cnkiGroups.value.length > 0) { + // 如果没有激活的tab或者当前tab不存在,切换到第一个 + if (!activeCnkiTab.value || !cnkiGroups.value.find(g => g.name === activeCnkiTab.value)) { + activeCnkiTab.value = cnkiGroups.value[0].name + } + } + } catch (e) { + // silent + } +} + +// 记录每个分组的选中项 +const onCnkiSelectionChange = (groupName, selection) => { + cnkiSelections.value = { + ...(cnkiSelections.value || {}), + [groupName]: selection + } +} + +// 将当前Tab选中的知网条目加入待下载 +const handleAddCnkiToDownload = async () => { + const selected = currentCnkiSelection.value || [] + if (!selected.length) { + ElMessage.warning('请先选择需要加入下载的记录') + return + } + try { + const ids = selected.map(item => item.id) + await generateDownloadTasks(inquiry.value.id, ids) + ElMessage.success(`已加入下载:${ids.length} 条`) + } catch (error) { + ElMessage.error('加入下载失败') + } +} + const isStructuredKeywords = (keywords) => { try { const parsed = JSON.parse(keywords) @@ -720,6 +979,26 @@ const getKeywordField = (keywords, field) => { const goToResponseView = () => { router.push(`/inquiry/${inquiry.value.id}/response-view`) } + +// 从content字段中提取卷期信息 +const extractVolumeIssue = (content) => { + if (!content) return '-' + const match = content.match(/卷:(\d+),\s*期:(\d+)/) + if (match) { + return `${match[1]}(${match[2]})` + } + return '-' +} + +// 从content字段中提取页码信息 +const extractPages = (content) => { + if (!content) return '-' + const match = content.match(/页码:([^,]+)/) + if (match) { + return match[1].trim() + } + return '-' +}