diff --git a/controller/OperationController.py b/controller/OperationController.py new file mode 100644 index 0000000..af300ad --- /dev/null +++ b/controller/OperationController.py @@ -0,0 +1,193 @@ +import json +import traceback +from app import app +from robyn import jsonify, Response +from service.OperationService import OperationService +from urllib.parse import unquote + +# 实例化业务逻辑对象 +operation_service = OperationService() + +def create_response(status_code, data_dict): + """ + 统一响应格式封装,强制使用 UTF-8 防止中文乱码。 + """ + return Response( + status_code=status_code, + description=jsonify(data_dict), + headers={"Content-Type": "application/json; charset=utf-8"} + ) + +def parse_request_body(req): + """ + 通用请求体解析器:解决解析 bytes、字符串或多重序列化的情况。 + """ + body = req.body + try: + if isinstance(body, (bytes, bytearray)): + body = json.loads(body.decode('utf-8')) + if isinstance(body, str): + body = json.loads(body) + return body if isinstance(body, dict) else {} + except Exception as e: + print(f"Request Body Parse Error: {e}") + return {} + +# --- 1. 获取全量动态标签 --- +@app.get("/api/kg/labels") +def get_labels(req): + try: + labels = operation_service.get_all_labels() + 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)}"}) + +# --- 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() + + 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)}) + +# --- 3. 获取分页节点列表 --- +@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")) + + res_data = operation_service.get_nodes_subset(page, page_size) + 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)}"}) + +# --- 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")) + + res_data = operation_service.get_relationships_subset(page, page_size) + 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)}"}) + +# --- 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() + + if not name: + 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"]}) + except Exception as e: + traceback.print_exc() + 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) + 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)"}) + + result = operation_service.update_node(node_id, name, label) + return create_response(200, {"code": 200 if result["success"] else 400, "msg": result["msg"]}) + except Exception as e: + traceback.print_exc() + return create_response(200, {"code": 500, "msg": 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() + + if not all([source, target, rel_type]): + 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"]}) + except Exception as e: + traceback.print_exc() + return create_response(200, {"code": 500, "msg": f"新增关系异常: {str(e)}"}) + +# --- 8. 修改关系 --- +@app.post("/api/kg/rel/update") +def update_rel(req): + try: + body = parse_request_body(req) + rel_id = body.get("id") + 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() + + if not all([rel_id, source, target, rel_type]): + return create_response(200, {"code": 400, "msg": "修改失败:必要参数(ID/Source/Target/Type)缺失"}) + + 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"]}) + except Exception as e: + traceback.print_exc() + return create_response(200, {"code": 500, "msg": str(e)}) + +# --- 9. 删除节点 --- +@app.post("/api/kg/node/delete") +def delete_node(req): + try: + body = parse_request_body(req) + node_id = body.get("id") + if not node_id: + return create_response(200, {"code": 400, "msg": "删除失败:节点ID不能为空"}) + + operation_service.delete_node(node_id) + return create_response(200, {"code": 200, "msg": "节点及其关联关系已成功删除"}) + except Exception as e: + traceback.print_exc() + return create_response(200, {"code": 500, "msg": str(e)}) + +# --- 10. 删除关系 --- +@app.post("/api/kg/rel/delete") +def delete_rel(req): + try: + body = parse_request_body(req) + rel_id = body.get("id") + if not rel_id: + return create_response(200, {"code": 400, "msg": "删除失败:关系ID不能为空"}) + + operation_service.delete_relationship(rel_id) + return create_response(200, {"code": 200, "msg": "关系已成功从数据库移除"}) + except Exception as e: + traceback.print_exc() + return create_response(200, {"code": 500, "msg": str(e)}) \ No newline at end of file diff --git a/service/OperationService.py b/service/OperationService.py new file mode 100644 index 0000000..4f59609 --- /dev/null +++ b/service/OperationService.py @@ -0,0 +1,203 @@ +from util.neo4j_utils import neo4j_client +from urllib.parse import unquote +import traceback + +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 + """ + try: + params = {"skip": skip_val, "limit": limit_val} + raw_data = self.db.execute_read(cypher, params) + if not raw_data: return {"items": [], "total": 0} + items = [] + for item in raw_data: + items.append({ + "id": item["id"], + "labels": item["labels"], + # 修复:不再强转为“未命名”,保留真实状态 + "name": item.get("name"), + "nodeId": item.get("nodeId") or 0 + }) + return {"items": items, "total": raw_data[0]['total']} + except Exception as e: + print(f"Service Error (Nodes): {e}") + 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 + """ + try: + params = {"skip": skip_val, "limit": limit_val} + raw_data = self.db.execute_read(cypher, params) + 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 "", + "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}") + return {"items": [], "total": 0} + + # --- 3. 联想建议:排除“未命名”干扰 --- + 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 + """ + 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}注射液"] + final_res = list(dict.fromkeys(db_suggestions + suffix_suggestions)) + return final_res[:15] + except: + return [] + + # --- 4. 节点管理 --- + def add_node(self, label: str, name: str): + try: + nm = str(name).strip() + if not nm: return {"success": False, "msg": "名称不能为空"} + + 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": "添加成功"} + 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() + 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}' 的节点"} + + cypher = f""" + MATCH (n) WHERE elementId(n) = $id + SET n.name = $name, n.nodeId = timestamp() + WITH n + REMOVE n:Drug:Disease:Symptom:Entity + 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": "找不到该节点"} + 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}) + + def get_all_labels(self): + cypher = "CALL db.labels()" + try: + results = self.db.execute_read(cypher) + labels = [list(row.values())[0] for row in results] + # 排除掉 Neo4j 默认的一些内部标签(如果有) + return labels if labels else ["Drug", "Disease", "Symptom"] + except: + return ["Drug", "Disease", "Symptom"] + + # --- 5. 关系管理 --- + 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() + + check_nodes = """ + OPTIONAL MATCH (a) WHERE a.name = $s + OPTIONAL MATCH (b) WHERE b.name = $t + RETURN a IS NOT NULL as hasA, b IS NOT NULL as hasB + """ + exists = self.db.execute_read(check_nodes, {"s": s, "t": t}) + + if not exists or not exists[0]['hasA'] or not exists[0]['hasB']: + err_msg = "添加失败: " + if not exists[0]['hasA']: err_msg += f"起始节点'{s}'不存在; " + if not exists[0]['hasB']: err_msg += f"结束节点'{t}'不存在" + return {"success": False, "msg": err_msg} + + 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() + RETURN r + """ + self.db.execute_write(cypher, {"s": s, "t": t, "l": l}) + return {"success": True, "msg": "操作成功"} + + except Exception as e: + traceback.print_exc() + return {"success": False, "msg": f"数据库写入异常: {str(e)}"} + + 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() + 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: + 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 diff --git a/vue/.npmrc b/vue/.npmrc new file mode 100644 index 0000000..a82f373 --- /dev/null +++ b/vue/.npmrc @@ -0,0 +1 @@ +overrides=entities@3.19.0 diff --git a/vue/src/router/index.js b/vue/src/router/index.js index a410e6a..60b8bda 100644 --- a/vue/src/router/index.js +++ b/vue/src/router/index.js @@ -7,6 +7,8 @@ import Display from '../system/GraphDemo.vue' import Builder from '../system/GraphBuilder.vue' import Style from '../system/GraphStyle.vue' import QA from '../system/GraphQA.vue' +import KGData from '../system/KGData.vue' + const routes = [ { path: '/', @@ -59,7 +61,12 @@ const routes = [ path: '/kg-qa', name: 'QA', component: QA - } + }, + { + path: '/kg-data', + name: 'KGData', + component: KGData + }, ] const router = createRouter({ diff --git a/vue/src/system/KGData.vue b/vue/src/system/KGData.vue new file mode 100644 index 0000000..b5da636 --- /dev/null +++ b/vue/src/system/KGData.vue @@ -0,0 +1,344 @@ + + + \ No newline at end of file diff --git a/web_main.py b/web_main.py index f121dbc..4ada4f4 100644 --- a/web_main.py +++ b/web_main.py @@ -3,6 +3,10 @@ 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") @@ -11,4 +15,7 @@ if os.path.exists(resource_dir): print(f"静态资源目录已配置: {resource_dir}") if __name__ == "__main__": - init_mysql_connection() and app.start(host="0.0.0.0", port=8088) \ No newline at end of file + # 启动服务 + # 确保 init_mysql_connection 返回 True 或者去掉 and 逻辑以保证 start 执行 + init_mysql_connection() + app.start(host="0.0.0.0", port=8088) \ No newline at end of file