diff --git a/controller/OperationController.py b/controller/OperationController.py index c1617a1..e2741cc 100644 --- a/controller/OperationController.py +++ b/controller/OperationController.py @@ -8,6 +8,7 @@ from urllib.parse import unquote # 实例化业务逻辑对象 operation_service = OperationService() + # --- 核心工具函数 --- def create_response(status_code, data_dict): @@ -20,6 +21,7 @@ def create_response(status_code, data_dict): headers={"Content-Type": "application/json; charset=utf-8"} ) + def parse_request_body(req): """ 解析器:适配 Robyn 框架,确保能准确拿到前端传来的 ID、nodeId、name 和 label。 @@ -57,6 +59,7 @@ def parse_request_body(req): print(f"Request Body Parse Error: {e}") return {} + def get_query_param(req, key, default=""): """ 提取 URL 查询参数。 @@ -79,7 +82,24 @@ def get_query_param(req, key, default=""): print(f"Get Param Error ({key}): {e}") return default -# --- 1. 获取全量动态标签 --- + +# --- 0. 数据治理修复接口 --- +@app.post("/api/kg/admin/fix-ids") +def fix_node_ids(req): + """ + 手动触发:修复数据库中 nodeId 为空或为 0 的存量数据 + """ + try: + result = operation_service.fix_all_missing_node_ids() + return create_response(200, { + "code": 200 if result.get("success") else 500, + "msg": result.get("msg") + }) + except Exception as e: + return create_response(200, {"code": 500, "msg": f"修复接口异常: {str(e)}"}) + + +# --- 1. 获取全量动态标签 (节点管理用) --- @app.get("/api/kg/labels") def get_labels(req): try: @@ -89,6 +109,21 @@ def get_labels(req): traceback.print_exc() return create_response(200, {"code": 500, "msg": f"获取标签失败: {str(e)}"}) + +# --- 新增:获取全量动态关系类型 (关系管理用) --- +@app.get("/api/kg/relationship-types") +def get_rel_types(req): + """ + 从数据库动态获取所有关系类型 type 及其 label 映射 + """ + try: + rel_types = operation_service.get_all_relationship_types() + return create_response(200, {"code": 200, "data": rel_types, "msg": "success"}) + except Exception as e: + traceback.print_exc() + return create_response(200, {"code": 500, "msg": f"获取关系类型失败: {str(e)}"}) + + # --- 2. 输入联想建议 --- @app.get("/api/kg/node/suggest") def suggest_node(req): @@ -99,7 +134,8 @@ def suggest_node(req): except Exception as e: return create_response(200, {"code": 500, "msg": str(e)}) -# --- 3. 获取分页节点列表 (无变动,Service已处理nodeId) --- + +# --- 3. 获取分页节点列表 --- @app.get("/api/kg/nodes") def get_nodes(req): try: @@ -120,6 +156,7 @@ def get_nodes(req): traceback.print_exc() return create_response(200, {"code": 500, "msg": f"获取节点失败: {str(e)}"}) + # --- 4. 获取分页关系列表 --- @app.get("/api/kg/relationships") def get_relationships(req): @@ -143,6 +180,7 @@ def get_relationships(req): traceback.print_exc() return create_response(200, {"code": 500, "msg": f"获取关系失败: {str(e)}"}) + # --- 5. 新增节点 --- @app.post("/api/kg/node/add") def add_node(req): @@ -150,9 +188,7 @@ def add_node(req): body = parse_request_body(req) label = str(body.get("label", "Drug")).strip() name = str(body.get("name", "")).strip() - # 注意:Service 里的 add_node 目前只接了 label 和 name, - # timestamp 由 Service 生成。如果以后需要接收前端传的 nodeId, - # 需在 Service 增加对应形参。目前保持兼容。 + if not name: return create_response(200, {"code": 400, "msg": "名称不能为空"}) @@ -164,12 +200,12 @@ def add_node(req): except Exception as e: return create_response(200, {"code": 500, "msg": f"新增异常: {str(e)}"}) + # --- 6. 修改节点 --- @app.post("/api/kg/node/update") def update_node(req): try: body = parse_request_body(req) - # elementId 依然作为核心修改标识 node_id = body.get("id") name = str(body.get("name", "")).strip() label = str(body.get("label", "")).strip() @@ -177,7 +213,6 @@ def update_node(req): if not node_id or not name: return create_response(200, {"code": 400, "msg": "参数缺失: 修改必须包含ID和名称"}) - # 调用 Service,注意参数顺序:node_id, name, label result = operation_service.update_node(node_id, name, label) return create_response(200, { "code": 200 if result.get("success") else 400, @@ -186,6 +221,7 @@ def update_node(req): except Exception as e: return create_response(200, {"code": 500, "msg": f"更新异常: {str(e)}"}) + # --- 7. 新增关系 --- @app.post("/api/kg/rel/add") def add_relationship(req): @@ -194,7 +230,6 @@ def add_relationship(req): source = str(body.get("source", "")).strip() target = str(body.get("target", "")).strip() rel_type = str(body.get("type", "")).strip() - # label 属性在 Neo4j 关系中常用于可视化展示 rel_label = str(body.get("label", "")).strip() or rel_type if not all([source, target, rel_type]): @@ -208,6 +243,7 @@ def add_relationship(req): except Exception as e: return create_response(200, {"code": 500, "msg": f"新增关系异常: {str(e)}"}) + # --- 8. 修改关系 --- @app.post("/api/kg/rel/update") def update_rel(req): @@ -230,12 +266,13 @@ def update_rel(req): except Exception as e: return create_response(200, {"code": 500, "msg": f"修改关系异常: {str(e)}"}) + # --- 9. 删除节点 --- @app.post("/api/kg/node/delete") def delete_node(req): try: body = parse_request_body(req) - node_id = body.get("id") # 这里传的是 elementId + node_id = body.get("id") if not node_id: return create_response(200, {"code": 400, "msg": "删除失败: 未指定节点系统ID"}) @@ -247,6 +284,7 @@ def delete_node(req): except Exception as e: return create_response(200, {"code": 500, "msg": f"删除节点异常: {str(e)}"}) + # --- 10. 删除关系 --- @app.post("/api/kg/rel/delete") def delete_rel(req): @@ -264,6 +302,7 @@ def delete_rel(req): except Exception as e: return create_response(200, {"code": 500, "msg": f"删除关系异常: {str(e)}"}) + # --- 11. 获取图谱全局统计数据 --- @app.get("/api/kg/stats") def get_kg_stats(req): diff --git a/service/OperationService.py b/service/OperationService.py index be9a4d7..7200d81 100644 --- a/service/OperationService.py +++ b/service/OperationService.py @@ -1,13 +1,36 @@ import json import traceback import datetime +import random +import time from urllib.parse import unquote from util.neo4j_utils import neo4j_client + class OperationService: def __init__(self): self.db = neo4j_client + # --- 0. 数据修复工具 --- + def fix_all_missing_node_ids(self): + try: + check_cypher = "MATCH (n) WHERE n.nodeId IS NULL OR n.nodeId = 0 OR n.nodeId = '0' RETURN count(n) as cnt" + res = self.db.execute_read(check_cypher) + if not res or res[0]['cnt'] == 0: + return {"success": True, "msg": "没有需要修复的节点"} + + update_cypher = """ + MATCH (n) + WHERE n.nodeId IS NULL OR n.nodeId = 0 OR n.nodeId = '0' + WITH n, toInteger(100000 + rand() * 899999) as newId + SET n.nodeId = newId + RETURN count(n) as fixedCount + """ + result = self.db.execute_write_and_return(update_cypher) + return {"success": True, "msg": f"修复完成,共处理 {result[0]['fixedCount']} 个节点"} + except Exception as e: + return {"success": False, "msg": f"修复失败: {str(e)}"} + # --- 1. 全局统计接口 --- def get_kg_stats(self): try: @@ -58,7 +81,6 @@ class OperationService: where_clause = "WHERE " + " AND ".join(conditions) if conditions else "" - # 核心 Cypher:同时返回 elementId(用于后端操作) 和 nodeId(用于前端展示) cypher = f""" MATCH (n) {where_clause} @@ -76,9 +98,10 @@ class OperationService: items = [] for item in raw_data: + db_node_id = item.get("nodeId") items.append({ - "id": item["id"], # 后端操作用的 elementId - "nodeId": item.get("nodeId") or item["id"], # 前端展示用的业务ID,若无则降级 + "id": item["id"], + "nodeId": db_node_id if (db_node_id and db_node_id != 0 and db_node_id != '0') else item["id"], "labels": item["labels"], "name": item.get("name") or "N/A" }) @@ -160,27 +183,31 @@ class OperationService: try: nm = unquote(str(name)).strip() if not nm: return {"success": False, "msg": "名称不能为空"} - create_time = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + now = datetime.datetime.now() + create_time = now.strftime('%Y-%m-%d %H:%M:%S') - # 查重 check_cypher = "MATCH (n) WHERE n.name = $name RETURN n LIMIT 1" existing = self.db.execute_read(check_cypher, {"name": nm}) if existing: return {"success": False, "msg": f"添加失败:已存在名为 '{nm}' 的节点"} - # nodeId 使用当前毫秒时间戳,确保其为业务可读的短ID + new_node_id = int(time.time() * 1000) + create_cypher = f""" CREATE (n:`{label}` {{ name: $name, - nodeId: timestamp(), + nodeId: $nodeId, createTime: $createTime }}) RETURN n """ - # 使用 write_and_return 确保能拿到返回对象,从而判断成功 - result = self.db.execute_write_and_return(create_cypher, {"name": nm, "createTime": create_time}) + result = self.db.execute_write_and_return(create_cypher, { + "name": nm, + "nodeId": new_node_id, + "createTime": create_time + }) if result: - return {"success": True, "msg": "添加成功"} + return {"success": True, "msg": "添加成功", "nodeId": new_node_id} return {"success": False, "msg": "节点创建失败"} except Exception as e: return {"success": False, "msg": f"写入失败: {str(e)}"} @@ -190,13 +217,11 @@ class OperationService: nm = unquote(str(name)).strip() if not nm: return {"success": False, "msg": "名称不能为空"} - # 排除自身查重 check_name = "MATCH (n) WHERE n.name = $name AND elementId(n) <> $id RETURN n LIMIT 1" existing = self.db.execute_read(check_name, {"name": nm, "id": node_id}) if existing: return {"success": False, "msg": f"修改失败:库中已有其他名为 '{nm}' 的节点"} - # 修改标签需要先移除旧标签(Neo4j不支持直接覆盖所有标签) cypher = f""" MATCH (n) WHERE elementId(n) = $id SET n.name = $name @@ -216,7 +241,6 @@ class OperationService: def delete_node(self, node_id: str): try: - # 使用 RETURN count(n) 来确认是否执行了删除 cypher = "MATCH (n) WHERE elementId(n) = $id DETACH DELETE n RETURN 1 as deleted" result = self.db.execute_write_and_return(cypher, {"id": node_id}) if result: @@ -229,24 +253,50 @@ class OperationService: cypher = "CALL db.labels()" try: results = self.db.execute_read(cypher) - # 处理 Neo4j 返回的列表格式 labels = [list(row.values())[0] for row in results] return labels if labels else ["Drug", "Disease", "Symptom"] except: return ["Drug", "Disease", "Symptom"] # --- 6. 关系管理 --- + + # 新增:动态获取全库关系类型及其对应的中文映射 + def get_all_relationship_types(self): + """ + 获取数据库中所有的关系类型 type 及其对应的中文 label 映射 + """ + cypher = """ + MATCH ()-[r]->() + RETURN DISTINCT type(r) AS type, r.label AS label + """ + try: + results = self.db.execute_read(cypher) + type_map = [] + seen_types = set() + for row in results: + t_name = row["type"] + t_label = row["label"] if row.get("label") else t_name # 若没label属性则降级显示type + + # 去重逻辑:确保每个 type 只出现一次(以第一个发现的 label 为准) + if t_name not in seen_types: + type_map.append({ + "type": t_name, + "label": t_label + }) + seen_types.add(t_name) + return type_map if type_map else [] + except Exception as e: + print(f"Fetch RelTypes Error: {e}") + return [] + def add_relationship(self, source_name: str, target_name: str, rel_type: str, rel_label: str): try: - # 1. 数据清洗 s = unquote(str(source_name)).strip() t = unquote(str(target_name)).strip() l = str(rel_label).strip() clean_rel_type = rel_type.strip().replace("`", "") create_time = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') - # 2. 预检:节点是否存在 (仿照 add_node 的 check_cypher) - # 使用 LIMIT 1 避免同名节点导致的笛卡尔积重复 check_nodes = """ OPTIONAL MATCH (a) WHERE a.name = $s WITH a LIMIT 1 @@ -262,14 +312,11 @@ class OperationService: if not nodes_res[0]['hasB']: err_msg += f"结束节点'{t}'不存在" return {"success": False, "msg": err_msg} - # 3. 查重:检查是否已存在相同关系 (仿照 add_node 的查重逻辑) check_rel = f"MATCH (a {{name: $s}})-[r:`{clean_rel_type}`]->(b {{name: $t}}) RETURN r LIMIT 1" existing_rel = self.db.execute_read(check_rel, {"s": s, "t": t}) if existing_rel: return {"success": False, "msg": f"添加失败:已存在该关系"} - # 4. 写入:创建新关系 - # 同样使用 WITH...LIMIT 1 确保即使有重名点也只连一对线 create_cypher = f""" MATCH (a {{name: $s}}), (b {{name: $t}}) WITH a, b LIMIT 1 @@ -301,13 +348,11 @@ class OperationService: old = self.db.execute_read(find_old, {"id": rel_id}) if not old: return {"success": False, "msg": "修改失败:原关系不存在"} - # 如果只是修改 label,不修改节点和类型,则直接 SET if old[0]['s'] == s and old[0]['t'] == t and old[0]['type'] == rel_type: update_cypher = "MATCH ()-[r]->() WHERE elementId(r) = $id SET r.label = $l RETURN r" self.db.execute_write_and_return(update_cypher, {"id": rel_id, "l": l}) return {"success": True, "msg": "修改成功"} else: - # 涉及节点或类型变动,由于 Neo4j 不支持直接更改关系类型,需删掉重建 self.delete_relationship(rel_id) return self.add_relationship(s, t, rel_type, l) except Exception as e: diff --git a/vue/src/api/data.js b/vue/src/api/data.js index 03f4ab7..05cd815 100644 --- a/vue/src/api/data.js +++ b/vue/src/api/data.js @@ -5,6 +5,17 @@ import request from '@/utils/request'; * 知识图谱管理接口 */ +// --- 存量数据 ID 自动修复 --- +/** + * 触发后端检查并修复 nodeId 为 0 或缺失的节点 + */ +export function fixNodeIds() { + return request({ + url: '/api/kg/admin/fix-ids', + method: 'post' + }) +} + // --- 0. 获取图谱全局统计数据 --- export function getKgStats() { return request({ @@ -13,7 +24,7 @@ export function getKgStats() { }) } -// --- 1. 获取全量动态标签 --- +// --- 1. 获取全量动态标签 (用于节点管理下拉框) --- export function getLabels() { return request({ url: '/api/kg/labels', @@ -21,6 +32,18 @@ export function getLabels() { }) } +// --- 新增:获取全量动态关系类型 (用于关系管理下拉框) --- +/** + * 从后端获取所有关系类型 type 及其对应的中文 label 映射 + * 返回格式示例: [{type: 'adverseReactions', label: '不良反应'}, ...] + */ +export function getRelationshipTypes() { + return request({ + url: '/api/kg/relationship-types', + method: 'get' + }) +} + // --- 2. 输入联想建议 --- export function getNodeSuggestions(keyword) { return request({ @@ -89,7 +112,6 @@ export function deleteNode(id) { return request({ url: '/api/kg/node/delete', method: 'post', - // 建议封装成对象,以便后端 parse_request_body 统一处理 data: { id } }) } diff --git a/vue/src/system/KGData.vue b/vue/src/system/KGData.vue index eb361e4..01d150b 100644 --- a/vue/src/system/KGData.vue +++ b/vue/src/system/KGData.vue @@ -72,7 +72,8 @@ @clear="handleNodeSearch" class="search-select"> - + @@ -87,7 +88,7 @@ @@ -133,6 +134,25 @@ />
+ 关系类型 + + + + +
+
结束节点 - + @@ -196,26 +216,27 @@ {{ currentDetail.nodeId }} {{ currentDetail.name }} - {{ l }} + {{ (currentDetail.labels || []).map(l => translateToChinese(l)).join(', ') }} - + - - + + @@ -227,25 +248,47 @@ - + - + - + - + + + - - + + @@ -253,31 +296,96 @@