diff --git a/controller/OperationController.py b/controller/OperationController.py index af300ad..c1617a1 100644 --- a/controller/OperationController.py +++ b/controller/OperationController.py @@ -8,6 +8,8 @@ from urllib.parse import unquote # 实例化业务逻辑对象 operation_service = OperationService() +# --- 核心工具函数 --- + def create_response(status_code, data_dict): """ 统一响应格式封装,强制使用 UTF-8 防止中文乱码。 @@ -20,19 +22,63 @@ def create_response(status_code, data_dict): def parse_request_body(req): """ - 通用请求体解析器:解决解析 bytes、字符串或多重序列化的情况。 + 解析器:适配 Robyn 框架,确保能准确拿到前端传来的 ID、nodeId、name 和 label。 """ - body = req.body try: + body = getattr(req, "body", None) + if not body: + return {} + + # 1. 处理 bytes 类型 if isinstance(body, (bytes, bytearray)): - body = json.loads(body.decode('utf-8')) + body = body.decode('utf-8') + + # 2. 如果已经是字典 + if isinstance(body, dict): + return body + + # 3. 处理字符串 if isinstance(body, str): - body = json.loads(body) - return body if isinstance(body, dict) else {} + try: + data = json.loads(body) + # 处理双层 JSON 字符串转义的情况 + if isinstance(data, str): + data = json.loads(data) + return data + except json.JSONDecodeError: + try: + from urllib.parse import parse_qs + params = parse_qs(body) + return {k: v[0] for k, v in params.items()} + except: + return {} + return {} except Exception as e: print(f"Request Body Parse Error: {e}") return {} +def get_query_param(req, key, default=""): + """ + 提取 URL 查询参数。 + """ + try: + data_source = getattr(req, "queries", None) + if data_source is None or (isinstance(data_source, dict) and not data_source): + data_source = getattr(req, "query_params", {}) + + if hasattr(data_source, "to_dict"): + data_source = data_source.to_dict() + + val = data_source.get(key) + if val is None: + return default + + raw_val = str(val[0]) if isinstance(val, list) else str(val) + return unquote(raw_val).strip() + except Exception as e: + print(f"Get Param Error ({key}): {e}") + return default + # --- 1. 获取全量动态标签 --- @app.get("/api/kg/labels") def get_labels(req): @@ -41,105 +87,125 @@ def get_labels(req): return create_response(200, {"code": 200, "data": labels, "msg": "success"}) except Exception as e: traceback.print_exc() - return create_response(500, {"code": 500, "msg": f"获取标签失败: {str(e)}"}) + return create_response(200, {"code": 500, "msg": f"获取标签失败: {str(e)}"}) # --- 2. 输入联想建议 --- @app.get("/api/kg/node/suggest") def suggest_node(req): try: - query_data = getattr(req, "queries", getattr(req, "query_params", {})) - raw_keyword = query_data.get("keyword", "") - keyword = raw_keyword[0] if isinstance(raw_keyword, list) and raw_keyword else str(raw_keyword) - clean_keyword = unquote(keyword).strip() - + clean_keyword = get_query_param(req, "keyword", "") suggestions = operation_service.suggest_nodes(clean_keyword) return create_response(200, {"code": 200, "data": suggestions, "msg": "success"}) except Exception as e: - traceback.print_exc() - return create_response(500, {"code": 500, "msg": str(e)}) + return create_response(200, {"code": 500, "msg": str(e)}) -# --- 3. 获取分页节点列表 --- +# --- 3. 获取分页节点列表 (无变动,Service已处理nodeId) --- @app.get("/api/kg/nodes") def get_nodes(req): try: - query_data = getattr(req, "queries", getattr(req, "query_params", {})) - page = int(query_data.get("page", "1")) - page_size = int(query_data.get("pageSize", "20")) + name_raw = get_query_param(req, "name", "") + label_raw = get_query_param(req, "label", "") + page_str = get_query_param(req, "page", "1") + size_str = get_query_param(req, "pageSize", "20") + + page = int(page_str) if page_str.isdigit() else 1 + page_size = int(size_str) if size_str.isdigit() else 20 - res_data = operation_service.get_nodes_subset(page, page_size) + name = name_raw if name_raw else None + label = label_raw if (label_raw and label_raw != "全部") else None + + res_data = operation_service.get_nodes_subset(page, page_size, name=name, label=label) return create_response(200, {"code": 200, "data": res_data, "msg": "success"}) except Exception as e: traceback.print_exc() - return create_response(500, {"code": 500, "msg": f"获取节点列表失败: {str(e)}"}) + return create_response(200, {"code": 500, "msg": f"获取节点失败: {str(e)}"}) # --- 4. 获取分页关系列表 --- @app.get("/api/kg/relationships") def get_relationships(req): try: - query_data = getattr(req, "queries", getattr(req, "query_params", {})) - page = int(query_data.get("page", "1")) - page_size = int(query_data.get("pageSize", "20")) + source_raw = get_query_param(req, "source", "") + target_raw = get_query_param(req, "target", "") + type_raw = get_query_param(req, "type", "") + page_str = get_query_param(req, "page", "1") + size_str = get_query_param(req, "pageSize", "20") + + page = int(page_str) if page_str.isdigit() else 1 + page_size = int(size_str) if size_str.isdigit() else 20 + + source = source_raw if source_raw else None + target = target_raw if target_raw else None + rel_type = type_raw if (type_raw and type_raw != "全部") else None - res_data = operation_service.get_relationships_subset(page, page_size) + res_data = operation_service.get_relationships_subset(page, page_size, source, target, rel_type) return create_response(200, {"code": 200, "data": res_data, "msg": "success"}) except Exception as e: traceback.print_exc() - return create_response(500, {"code": 500, "msg": f"获取关系列表失败: {str(e)}"}) + return create_response(200, {"code": 500, "msg": f"获取关系失败: {str(e)}"}) # --- 5. 新增节点 --- @app.post("/api/kg/node/add") def add_node(req): try: body = parse_request_body(req) - # 增加 strip() 防止空格导致的匹配失败 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": "添加失败:节点名称不能为空"}) + return create_response(200, {"code": 400, "msg": "名称不能为空"}) result = operation_service.add_node(label, name) - return create_response(200, {"code": 200 if result["success"] else 400, "msg": result["msg"]}) + return create_response(200, { + "code": 200 if result.get("success") else 400, + "msg": result.get("msg") + }) except Exception as e: - traceback.print_exc() - return create_response(200, {"code": 500, "msg": f"服务器内部错误: {str(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() if not node_id or not name: - return create_response(200, {"code": 400, "msg": "修改失败:参数缺失(ID或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["success"] else 400, "msg": result["msg"]}) + return create_response(200, { + "code": 200 if result.get("success") else 400, + "msg": result.get("msg") + }) except Exception as e: - traceback.print_exc() - return create_response(200, {"code": 500, "msg": str(e)}) + return create_response(200, {"code": 500, "msg": f"更新异常: {str(e)}"}) # --- 7. 新增关系 --- @app.post("/api/kg/rel/add") def add_relationship(req): try: body = parse_request_body(req) - # 强制格式化所有字符串,彻底杜绝数据类型导致的 MATCH 失败 source = str(body.get("source", "")).strip() target = str(body.get("target", "")).strip() rel_type = str(body.get("type", "")).strip() - rel_label = str(body.get("label", "")).strip() + # label 属性在 Neo4j 关系中常用于可视化展示 + rel_label = str(body.get("label", "")).strip() or rel_type if not all([source, target, rel_type]): - return create_response(200, {"code": 400, "msg": "添加失败:起始点、结束点和类型不能为空"}) + return create_response(200, {"code": 400, "msg": "参数缺失: 起点、终点和类型为必填项"}) result = operation_service.add_relationship(source, target, rel_type, rel_label) - return create_response(200, {"code": 200 if result["success"] else 400, "msg": result["msg"]}) + return create_response(200, { + "code": 200 if result.get("success") else 400, + "msg": result.get("msg") + }) except Exception as e: - traceback.print_exc() return create_response(200, {"code": 500, "msg": f"新增关系异常: {str(e)}"}) # --- 8. 修改关系 --- @@ -151,31 +217,35 @@ def update_rel(req): source = str(body.get("source", "")).strip() target = str(body.get("target", "")).strip() rel_type = str(body.get("type", "")).strip() - rel_label = str(body.get("label", "")).strip() + rel_label = str(body.get("label", "")).strip() or rel_type - if not all([rel_id, source, target, rel_type]): - return create_response(200, {"code": 400, "msg": "修改失败:必要参数(ID/Source/Target/Type)缺失"}) + if not rel_id: + return create_response(200, {"code": 400, "msg": "修改失败:关系ID缺失"}) result = operation_service.update_relationship(rel_id, source, target, rel_type, rel_label) - return create_response(200, {"code": 200 if result["success"] else 400, "msg": result["msg"]}) + return create_response(200, { + "code": 200 if result.get("success") else 400, + "msg": result.get("msg") + }) except Exception as e: - traceback.print_exc() - return create_response(200, {"code": 500, "msg": str(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") + node_id = body.get("id") # 这里传的是 elementId if not node_id: - return create_response(200, {"code": 400, "msg": "删除失败:节点ID不能为空"}) + return create_response(200, {"code": 400, "msg": "删除失败: 未指定节点系统ID"}) - operation_service.delete_node(node_id) - return create_response(200, {"code": 200, "msg": "节点及其关联关系已成功删除"}) + result = operation_service.delete_node(node_id) + return create_response(200, { + "code": 200 if result.get("success") else 400, + "msg": result.get("msg") + }) except Exception as e: - traceback.print_exc() - return create_response(200, {"code": 500, "msg": str(e)}) + return create_response(200, {"code": 500, "msg": f"删除节点异常: {str(e)}"}) # --- 10. 删除关系 --- @app.post("/api/kg/rel/delete") @@ -184,10 +254,26 @@ def delete_rel(req): body = parse_request_body(req) rel_id = body.get("id") if not rel_id: - return create_response(200, {"code": 400, "msg": "删除失败:关系ID不能为空"}) + return create_response(200, {"code": 400, "msg": "删除失败: 未指定关系系统ID"}) + + result = operation_service.delete_relationship(rel_id) + return create_response(200, { + "code": 200 if result.get("success") else 400, + "msg": result.get("msg") + }) + except Exception as e: + return create_response(200, {"code": 500, "msg": f"删除关系异常: {str(e)}"}) - operation_service.delete_relationship(rel_id) - return create_response(200, {"code": 200, "msg": "关系已成功从数据库移除"}) +# --- 11. 获取图谱全局统计数据 --- +@app.get("/api/kg/stats") +def get_kg_stats(req): + try: + result = operation_service.get_kg_stats() + if result and result.get("success"): + return create_response(200, {"code": 200, "data": result.get("data"), "msg": "success"}) + else: + msg = result.get("msg") if result else "未能获取统计数据" + return create_response(200, {"code": 400, "msg": msg}) except Exception as e: traceback.print_exc() - return create_response(200, {"code": 500, "msg": str(e)}) \ No newline at end of file + return create_response(200, {"code": 500, "msg": f"统计数据异常: {str(e)}"}) \ No newline at end of file diff --git a/controller/__init__.py b/controller/__init__.py index 7609439..8104eb4 100644 --- a/controller/__init__.py +++ b/controller/__init__.py @@ -11,4 +11,4 @@ from .OperationController import * # from .BuilderController import builder_bp # from .GraphController import graph_bp -# from .LoginController import login_bp \ No newline at end of file +# from .LoginController import login_bp diff --git a/service/OperationService.py b/service/OperationService.py index 4f59609..be9a4d7 100644 --- a/service/OperationService.py +++ b/service/OperationService.py @@ -1,90 +1,152 @@ -from util.neo4j_utils import neo4j_client -from urllib.parse import unquote +import json import traceback +import datetime +from urllib.parse import unquote +from util.neo4j_utils import neo4j_client class OperationService: def __init__(self): self.db = neo4j_client - # --- 1. 节点查询:去掉强制命名的逻辑 --- - def get_nodes_subset(self, page: int = 1, page_size: int = 20): - skip_val = int((page - 1) * page_size) - limit_val = int(page_size) - # 去掉 Cypher 中的排序干扰,确保 ID 稳定 - cypher = """ - MATCH (n) - WITH count(n) AS total_count - MATCH (n) - RETURN elementId(n) AS id, labels(n) AS labels, n.name AS name, n.nodeId AS nodeId, total_count AS total - ORDER BY toInteger(n.nodeId) DESC - SKIP $skip LIMIT $limit - """ + # --- 1. 全局统计接口 --- + def get_kg_stats(self): try: + today_str = datetime.datetime.now().strftime('%Y-%m-%d') + cypher = """ + CALL () { + MATCH (n) RETURN count(n) AS totalNodes + } + CALL () { + MATCH ()-[r]->() RETURN count(r) AS totalRels + } + CALL () { + MATCH (n) WHERE n.createTime STARTS WITH $today RETURN count(n) AS todayNodes + } + RETURN totalNodes, totalRels, todayNodes + """ + results = self.db.execute_read(cypher, {"today": today_str}) + if results: + return { + "success": True, + "data": { + "totalNodes": results[0]['totalNodes'], + "totalRels": results[0]['totalRels'], + "todayNodes": results[0]['todayNodes'] + } + } + return {"success": False, "msg": "未能获取统计数据"} + except Exception as e: + print(f"Stats Error: {e}") + return {"success": False, "data": {"totalNodes": 0, "totalRels": 0, "todayNodes": 0}} + + # --- 2. 节点查询 --- + def get_nodes_subset(self, page=1, page_size=20, name=None, label=None): + try: + skip_val = (int(page) - 1) * int(page_size) + limit_val = int(page_size) + conditions = [] params = {"skip": skip_val, "limit": limit_val} + + if name: + decoded_name = unquote(str(name)).strip() + conditions.append("n.name CONTAINS $name") + params["name"] = decoded_name + + if label and str(label).strip() and label not in ["全部", ""]: + conditions.append("ANY(l IN labels(n) WHERE l = $label)") + params["label"] = str(label).strip() + + where_clause = "WHERE " + " AND ".join(conditions) if conditions else "" + + # 核心 Cypher:同时返回 elementId(用于后端操作) 和 nodeId(用于前端展示) + cypher = f""" + MATCH (n) + {where_clause} + WITH count(n) AS total_count + MATCH (n) + {where_clause} + RETURN elementId(n) AS id, labels(n) AS labels, n.name AS name, n.nodeId AS nodeId, total_count AS total + ORDER BY coalesce(n.createTime, '0000-00-00') DESC, toInteger(coalesce(n.nodeId, 0)) DESC + SKIP $skip LIMIT $limit + """ + raw_data = self.db.execute_read(cypher, params) - if not raw_data: return {"items": [], "total": 0} + if not raw_data: + return {"items": [], "total": 0} + items = [] for item in raw_data: items.append({ - "id": item["id"], + "id": item["id"], # 后端操作用的 elementId + "nodeId": item.get("nodeId") or item["id"], # 前端展示用的业务ID,若无则降级 "labels": item["labels"], - # 修复:不再强转为“未命名”,保留真实状态 - "name": item.get("name"), - "nodeId": item.get("nodeId") or 0 + "name": item.get("name") or "N/A" }) return {"items": items, "total": raw_data[0]['total']} except Exception as e: - print(f"Service Error (Nodes): {e}") + traceback.print_exc() return {"items": [], "total": 0} - # --- 2. 关系查询:去掉 coalesce 强制命名 --- - def get_relationships_subset(self, page: int = 1, page_size: int = 20): - skip_val = int((page - 1) * page_size) - limit_val = int(page_size) - # 修复:移除 coalesce(..., "未知节点") - cypher = """ - MATCH (a)-[r]->(b) - WITH count(r) AS total_count - MATCH (a)-[r]->(b) - RETURN elementId(r) as id, - type(r) as type, - r.label as label, - a.name as source, - b.name as target, - coalesce(r.createTime, 0) as createTime, - total_count - ORDER BY createTime DESC - SKIP $skip LIMIT $limit - """ + # --- 3. 关系查询 --- + def get_relationships_subset(self, page=1, page_size=20, source=None, target=None, rel_type=None): try: + skip_val = (int(page) - 1) * int(page_size) + limit_val = int(page_size) + conditions = [] params = {"skip": skip_val, "limit": limit_val} + + if source: + params["source"] = unquote(str(source)).strip() + conditions.append("a.name CONTAINS $source") + if target: + params["target"] = unquote(str(target)).strip() + conditions.append("b.name CONTAINS $target") + if rel_type and rel_type not in ["全部", ""]: + conditions.append("type(r) = $rel_type") + params["rel_type"] = str(rel_type).strip() + + where_clause = "WHERE " + " AND ".join(conditions) if conditions else "" + + cypher = f""" + MATCH (a)-[r]->(b) + {where_clause} + WITH count(r) AS total_count + MATCH (a)-[r]->(b) + {where_clause} + RETURN elementId(r) as id, + type(r) as type, + r.label as label, + a.name as source, + b.name as target, + total_count + ORDER BY coalesce(r.createTime, '0000-00-00') DESC + SKIP $skip LIMIT $limit + """ + raw_data = self.db.execute_read(cypher, params) - if not raw_data: return {"items": [], "total": 0} + if not raw_data: + return {"items": [], "total": 0} + items = [] for row in raw_data: items.append({ "id": row["id"], "type": row["type"], - "label": row["label"] if row["label"] is not None else "", + "label": row["label"] if row.get("label") is not None else "", "source": row["source"], "target": row["target"] }) return {"items": items, "total": raw_data[0]['total_count']} except Exception as e: - print(f"Service Error (Rels): {e}") + traceback.print_exc() return {"items": [], "total": 0} - # --- 3. 联想建议:排除“未命名”干扰 --- + # --- 4. 联想建议 --- def suggest_nodes(self, keyword: str): if not keyword: return [] try: kw = unquote(str(keyword)).strip() - # 增加过滤:不返回名为“未命名”的建议 - cypher = """ - MATCH (n) - WHERE n.name CONTAINS $kw AND n.name <> '未命名' - RETURN DISTINCT n.name as name LIMIT 15 - """ + cypher = "MATCH (n) WHERE n.name CONTAINS $kw AND n.name <> '未命名' RETURN DISTINCT n.name as name LIMIT 15" results = self.db.execute_read(cypher, {"kw": kw}) db_suggestions = [row["name"] for row in results if row["name"]] suffix_suggestions = [f"{kw}片", f"{kw}胶囊", f"{kw}注射液"] @@ -93,88 +155,137 @@ class OperationService: except: return [] - # --- 4. 节点管理 --- + # --- 5. 节点管理 --- def add_node(self, label: str, name: str): try: - nm = str(name).strip() + nm = unquote(str(name)).strip() if not nm: return {"success": False, "msg": "名称不能为空"} + create_time = datetime.datetime.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}' 的节点"} - create_cypher = f"CREATE (n:`{label}` {{name: $name, nodeId: timestamp()}}) RETURN elementId(n) as id" - self.db.execute_write(create_cypher, {"name": nm}) - return {"success": True, "msg": "添加成功"} + # nodeId 使用当前毫秒时间戳,确保其为业务可读的短ID + create_cypher = f""" + CREATE (n:`{label}` {{ + name: $name, + nodeId: timestamp(), + createTime: $createTime + }}) + RETURN n + """ + # 使用 write_and_return 确保能拿到返回对象,从而判断成功 + result = self.db.execute_write_and_return(create_cypher, {"name": nm, "createTime": create_time}) + if result: + return {"success": True, "msg": "添加成功"} + return {"success": False, "msg": "节点创建失败"} except Exception as e: return {"success": False, "msg": f"写入失败: {str(e)}"} def update_node(self, node_id: str, name: str, label: str): try: - nm = str(name).strip() + 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, n.nodeId = timestamp() + SET n.name = $name WITH n - REMOVE n:Drug:Disease:Symptom:Entity + REMOVE n:Drug:Disease:Symptom:Entity:Medicine:Check:Food WITH n SET n:`{label}` RETURN n """ - result = self.db.execute_write(cypher, {"id": node_id, "name": nm}) - return {"success": True, "msg": "节点修改成功"} if result else {"success": False, "msg": "找不到该节点"} + result = self.db.execute_write_and_return(cypher, {"id": node_id, "name": nm}) + if result: + return {"success": True, "msg": "节点修改成功"} + else: + return {"success": False, "msg": "找不到该节点或更新失败"} except Exception as e: return {"success": False, "msg": str(e)} def delete_node(self, node_id: str): - cypher = "MATCH (n) WHERE elementId(n) = $id DETACH DELETE n" - return self.db.execute_write(cypher, {"id": node_id}) + 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: + return {"success": True, "msg": "删除成功"} + return {"success": False, "msg": "节点不存在或已被删除"} + except Exception as e: + return {"success": False, "msg": str(e)} def get_all_labels(self): cypher = "CALL db.labels()" try: results = self.db.execute_read(cypher) + # 处理 Neo4j 返回的列表格式 labels = [list(row.values())[0] for row in results] - # 排除掉 Neo4j 默认的一些内部标签(如果有) return labels if labels else ["Drug", "Disease", "Symptom"] except: return ["Drug", "Disease", "Symptom"] - # --- 5. 关系管理 --- + # --- 6. 关系管理 --- def add_relationship(self, source_name: str, target_name: str, rel_type: str, rel_label: str): try: - s, t, l = str(source_name).strip(), str(target_name).strip(), str(rel_label).strip() + # 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 OPTIONAL MATCH (b) WHERE b.name = $t + WITH a, b LIMIT 1 RETURN a IS NOT NULL as hasA, b IS NOT NULL as hasB """ - exists = self.db.execute_read(check_nodes, {"s": s, "t": t}) + nodes_res = self.db.execute_read(check_nodes, {"s": s, "t": t}) - if not exists or not exists[0]['hasA'] or not exists[0]['hasB']: + if not nodes_res or not nodes_res[0]['hasA'] or not nodes_res[0]['hasB']: err_msg = "添加失败: " - if not exists[0]['hasA']: err_msg += f"起始节点'{s}'不存在; " - if not exists[0]['hasB']: err_msg += f"结束节点'{t}'不存在" + if not nodes_res[0]['hasA']: err_msg += f"起始节点'{s}'不存在; " + if not nodes_res[0]['hasB']: err_msg += f"结束节点'{t}'不存在" return {"success": False, "msg": err_msg} - cypher = f""" + # 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}}) - MERGE (a)-[r:`{rel_type}`]->(b) - ON CREATE SET r.label = $l, r.createTime = timestamp() - ON MATCH SET r.label = $l, r.updateTime = timestamp() + WITH a, b LIMIT 1 + CREATE (a)-[r:`{clean_rel_type}` {{ + label: $l, + createTime: $create_time + }}]->(b) RETURN r """ - self.db.execute_write(cypher, {"s": s, "t": t, "l": l}) - return {"success": True, "msg": "操作成功"} + result = self.db.execute_write_and_return(create_cypher, { + "s": s, "t": t, "l": l, "create_time": create_time + }) + + if result: + return {"success": True, "msg": "添加成功"} + return {"success": False, "msg": "关系创建失败"} except Exception as e: traceback.print_exc() @@ -182,22 +293,31 @@ class OperationService: def update_relationship(self, rel_id: str, source_name: str, target_name: str, rel_type: str, rel_label: str): try: - s, t, l = str(source_name).strip(), str(target_name).strip(), str(rel_label).strip() + s = unquote(str(source_name)).strip() + t = unquote(str(target_name)).strip() + l = str(rel_label).strip() + find_old = "MATCH (a)-[r]->(b) WHERE elementId(r) = $id RETURN type(r) as type, a.name as s, b.name as t" old = self.db.execute_read(find_old, {"id": rel_id}) if not old: return {"success": False, "msg": "修改失败:原关系不存在"} - if old[0]['s'] != s or old[0]['t'] != t or old[0]['type'] != rel_type: + # 如果只是修改 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) - else: - update_cypher = "MATCH ()-[r]->() WHERE elementId(r) = $id SET r.label = $l, r.updateTime = timestamp() RETURN r" - self.db.execute_write(update_cypher, {"id": rel_id, "l": l}) - return {"success": True, "msg": "修改成功"} except Exception as e: traceback.print_exc() return {"success": False, "msg": f"修改异常: {str(e)}"} def delete_relationship(self, rel_id: str): - cypher = "MATCH ()-[r]->() WHERE elementId(r) = $id DELETE r" - return self.db.execute_write(cypher, {"id": rel_id}) \ No newline at end of file + try: + cypher = "MATCH ()-[r]->() WHERE elementId(r) = $id DELETE r RETURN 1" + result = self.db.execute_write_and_return(cypher, {"id": rel_id}) + return {"success": True, "msg": "删除成功"} if result else {"success": False, "msg": "关系不存在"} + except Exception as e: + return {"success": False, "msg": f"删除失败: {str(e)}"} \ No newline at end of file diff --git a/util/neo4j_utils.py b/util/neo4j_utils.py index 78386cf..0546253 100644 --- a/util/neo4j_utils.py +++ b/util/neo4j_utils.py @@ -48,6 +48,7 @@ class Neo4jUtil: session.execute_write( lambda tx: tx.run(cypher, parameters=params).consume() ) + print(cypher) logger.debug(f"执行写操作: {cypher}") def execute_read(self, cypher: str, params: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: diff --git a/vue/src/api/data.js b/vue/src/api/data.js new file mode 100644 index 0000000..03f4ab7 --- /dev/null +++ b/vue/src/api/data.js @@ -0,0 +1,104 @@ +// src/api/data.js +import request from '@/utils/request'; + +/** + * 知识图谱管理接口 + */ + +// --- 0. 获取图谱全局统计数据 --- +export function getKgStats() { + return request({ + url: '/api/kg/stats', + method: 'get' + }) +} + +// --- 1. 获取全量动态标签 --- +export function getLabels() { + return request({ + url: '/api/kg/labels', + method: 'get' + }) +} + +// --- 2. 输入联想建议 --- +export function getNodeSuggestions(keyword) { + return request({ + url: '/api/kg/node/suggest', + method: 'get', + params: { keyword } + }) +} + +// --- 3. 获取分页节点列表 --- +export function getNodesList(params) { + return request({ + url: '/api/kg/nodes', + method: 'get', + params // 包含 page, pageSize, name, label + }) +} + +// --- 4. 获取分页关系列表 --- +export function getRelationshipsList(params) { + return request({ + url: '/api/kg/relationships', + method: 'get', + params // 包含 page, pageSize, source, target, type + }) +} + +// --- 5. 新增节点 --- +export function addNode(data) { + return request({ + url: '/api/kg/node/add', + method: 'post', + data // 格式: { label, name } + }) +} + +// --- 6. 修改节点 --- +export function updateNode(data) { + return request({ + url: '/api/kg/node/update', + method: 'post', + data + }) +} + +// --- 7. 新增关系 --- +export function addRelationship(data) { + return request({ + url: '/api/kg/rel/add', + method: 'post', + data // 格式: { source, target, type, label } + }) +} + +// --- 8. 修改关系 --- +export function updateRelationship(data) { + return request({ + url: '/api/kg/rel/update', + method: 'post', + data // 格式: { id, source, target, type, label } + }) +} + +// --- 9. 删除节点 --- +export function deleteNode(id) { + return request({ + url: '/api/kg/node/delete', + method: 'post', + // 建议封装成对象,以便后端 parse_request_body 统一处理 + data: { id } + }) +} + +// --- 10. 删除关系 --- +export function deleteRelationship(id) { + return request({ + url: '/api/kg/rel/delete', + method: 'post', + data: { id } + }) +} \ No newline at end of file diff --git a/vue/src/components/Menu.vue b/vue/src/components/Menu.vue index 7eb2d53..ffca0a5 100644 --- a/vue/src/components/Menu.vue +++ b/vue/src/components/Menu.vue @@ -1,13 +1,13 @@ \ No newline at end of file diff --git a/vue/src/main.js b/vue/src/main.js index fb2b388..8b9da23 100644 --- a/vue/src/main.js +++ b/vue/src/main.js @@ -4,8 +4,11 @@ import router from './router' // 👈 引入路由 // 引入 Element Plus import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' // 引入样式 +import zhCn from 'element-plus/es/locale/lang/zh-cn' const app = createApp(App) app.use(router) // 👈 关键!注册 router 插件 -app.use(ElementPlus) // 全局注册 +app.use(ElementPlus, { + locale : zhCn, +}) // 全局注册 app.mount('#app') diff --git a/vue/src/system/KGData.vue b/vue/src/system/KGData.vue index b5da636..bf717a1 100644 --- a/vue/src/system/KGData.vue +++ b/vue/src/system/KGData.vue @@ -1,344 +1,479 @@ \ No newline at end of file + fetchStats(); + fetchNodes(); + getLabels().then(res => { if(res?.code === 200) dynamicLabels.value = res.data; }); +}); + + + \ No newline at end of file diff --git a/web_main.py b/web_main.py index 479d416..5f8d544 100644 --- a/web_main.py +++ b/web_main.py @@ -5,10 +5,6 @@ import controller from service.UserService import init_mysql_connection import os -# 开启全局跨域支持,允许所有来源访问 -# 这将解决浏览器报 "CORS error" 或请求被拦截的问题 -ALLOW_CORS(app, ["*"]) - # 添加静态文件服务 current_dir = os.path.dirname(os.path.abspath(__file__)) resource_dir = os.path.join(current_dir, "resource") @@ -17,7 +13,4 @@ if os.path.exists(resource_dir): print(f"静态资源目录已配置: {resource_dir}") if __name__ == "__main__": - # 启动服务 - # 确保 init_mysql_connection 返回 True 或者去掉 and 逻辑以保证 start 执行 - init_mysql_connection() - app.start(host="0.0.0.0", port=8088) \ No newline at end of file + init_mysql_connection() and app.start(host="0.0.0.0", port=8088) \ No newline at end of file