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 facd7ed..01d150b 100644
--- a/vue/src/system/KGData.vue
+++ b/vue/src/system/KGData.vue
@@ -72,7 +72,8 @@
@clear="handleNodeSearch"
class="search-select">