dataflows.py 94 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325
  1. import contextlib
  2. import copy
  3. import json
  4. import logging
  5. import os
  6. import uuid
  7. from datetime import datetime
  8. from pathlib import Path
  9. from typing import Any, Dict, List, Optional, Union
  10. from sqlalchemy import text
  11. from app import db
  12. from app.core.common.identifiers import ensure_governance_uid
  13. from app.core.data_rules.contracts import validate_dataflow_spec
  14. from app.core.data_service.data_product_service import DataProductService
  15. from app.core.graph.graph_operations import (
  16. connect_graph,
  17. create_or_get_node,
  18. get_node,
  19. relationship_exists,
  20. )
  21. from app.core.meta_data import get_formatted_time, translate_and_parse
  22. logger = logging.getLogger(__name__)
  23. # 项目根目录
  24. PROJECT_ROOT = Path(__file__).parent.parent.parent.parent
  25. class DataFlowService:
  26. """数据流服务类,处理数据流相关的业务逻辑"""
  27. _GOVERNED_REQUIREMENT_KEYS = {
  28. "dataflow_spec",
  29. "dataset_edges",
  30. "migration_metadata",
  31. }
  32. _MIGRATION_METADATA_KEYS = {
  33. "status",
  34. "legacy_fields_present",
  35. "preserved_for_read_only",
  36. "governed_semantics",
  37. }
  38. _GOVERNED_PAYLOAD_FIELDS = {
  39. "draft_reservation",
  40. "dataflow_spec",
  41. "dataset_edges",
  42. "migration_metadata",
  43. }
  44. _GOVERNED_FORBIDDEN_FIELDS = {
  45. "code",
  46. "generated_code",
  47. "legacy_script_path",
  48. "n8n_workflow_id",
  49. "task_list",
  50. "workflow",
  51. }
  52. _GOVERNED_NODE_KEYS = {
  53. "uid",
  54. "name_zh",
  55. "name_en",
  56. "category",
  57. "organization",
  58. "leader",
  59. "frequency",
  60. "describe",
  61. "status",
  62. "update_mode",
  63. "script_type",
  64. "script_requirement",
  65. "script_path",
  66. }
  67. @staticmethod
  68. def _decode_script_requirement(value: Any) -> Any:
  69. if not isinstance(value, str):
  70. return copy.deepcopy(value)
  71. try:
  72. return json.loads(value)
  73. except (TypeError, json.JSONDecodeError):
  74. return value
  75. @classmethod
  76. def _signals_governed(cls, data: dict[str, Any], requirement: Any) -> bool:
  77. return (
  78. data.get("script_type") == "governed"
  79. or bool(set(data) & cls._GOVERNED_PAYLOAD_FIELDS)
  80. or (
  81. isinstance(requirement, dict)
  82. and bool(set(requirement) & cls._GOVERNED_REQUIREMENT_KEYS)
  83. )
  84. )
  85. @classmethod
  86. def _reject_governed_execution_fields(cls, data: dict[str, Any]) -> None:
  87. if set(data) & cls._GOVERNED_FORBIDDEN_FIELDS:
  88. raise ValueError(
  89. "governed DataFlow cannot contain legacy execution fields"
  90. )
  91. if data.get("script_type") not in {None, "governed"}:
  92. raise ValueError("governed DataFlow script_type must be governed")
  93. if data.get("script_path") not in {None, ""}:
  94. raise ValueError("governed DataFlow cannot define script_path")
  95. @classmethod
  96. def validate_governed_requirement(
  97. cls, value: Any, *, repository
  98. ) -> Dict[str, Any]:
  99. """Validate the closed, published-asset DataFlow save envelope."""
  100. requirement = cls._decode_script_requirement(value)
  101. if not isinstance(requirement, dict):
  102. raise ValueError("governed script_requirement must be an object")
  103. if set(requirement) != cls._GOVERNED_REQUIREMENT_KEYS:
  104. raise ValueError(
  105. "governed script_requirement contains unsupported fields"
  106. )
  107. flow = validate_dataflow_spec(requirement.get("dataflow_spec"))
  108. edges = requirement.get("dataset_edges")
  109. if not isinstance(edges, dict) or set(edges) != {
  110. "source_table",
  111. "target_table",
  112. }:
  113. raise ValueError("dataset edges must be a closed object")
  114. if (
  115. edges.get("source_table") != flow["input_schema_refs"]
  116. or edges.get("target_table") != flow["output_schema_ref"]
  117. ):
  118. raise ValueError("dataset edges must match the DataFlow schema refs")
  119. metadata = requirement.get("migration_metadata")
  120. if (
  121. not isinstance(metadata, dict)
  122. or set(metadata) != cls._MIGRATION_METADATA_KEYS
  123. ):
  124. raise ValueError("migration metadata must be a closed object")
  125. if metadata.get("status") not in {"migrated", "unmigrated"}:
  126. raise ValueError("unsupported migration status")
  127. if type(metadata.get("legacy_fields_present")) is not bool:
  128. raise ValueError("legacy_fields_present must be a boolean")
  129. if metadata.get("preserved_for_read_only") is not True:
  130. raise ValueError("legacy fields must be preserved for read-only use")
  131. if metadata.get("governed_semantics") != "dataflow_spec":
  132. raise ValueError("unsupported governed semantics")
  133. repository.load_published_assets(flow)
  134. return {
  135. "dataflow_spec": flow,
  136. "dataset_edges": {
  137. "source_table": list(flow["input_schema_refs"]),
  138. "target_table": flow["output_schema_ref"],
  139. },
  140. "migration_metadata": copy.deepcopy(metadata),
  141. }
  142. @staticmethod
  143. def _is_dataflow_constraint_conflict(error: Exception) -> bool:
  144. code = str(getattr(error, "code", "") or "")
  145. return code in {
  146. "Neo.ClientError.Schema.ConstraintCreationFailed",
  147. "Neo.ClientError.Schema.ConstraintValidationFailed",
  148. "Neo.ClientError.Schema.ConstraintVerificationFailed",
  149. }
  150. @staticmethod
  151. def _install_dataflow_constraints(session) -> None:
  152. """Install both identity constraints, retrying only schema deadlocks."""
  153. for attempt in range(3):
  154. try:
  155. session.run(
  156. "CREATE CONSTRAINT data_flow_uid IF NOT EXISTS "
  157. "FOR (n:DataFlow) REQUIRE n.uid IS UNIQUE"
  158. ).consume()
  159. session.run(
  160. "CREATE CONSTRAINT data_flow_name_zh IF NOT EXISTS "
  161. "FOR (n:DataFlow) REQUIRE n.name_zh IS UNIQUE"
  162. ).consume()
  163. return
  164. except Exception as exc:
  165. if DataFlowService._is_dataflow_constraint_conflict(exc):
  166. raise ValueError("dataflow_uid_conflict") from exc
  167. code = str(getattr(exc, "code", "") or "")
  168. if (
  169. code
  170. == "Neo.TransientError.Transaction.DeadlockDetected"
  171. and attempt < 2
  172. ):
  173. continue
  174. raise
  175. raise RuntimeError("DataFlow constraints were not installed")
  176. @staticmethod
  177. def _merge_governed_dataflow(node_data: dict[str, Any]) -> tuple[int, dict]:
  178. """Idempotently create by stable UID and reject immutable conflicts."""
  179. node_data = copy.deepcopy(node_data)
  180. node_data.setdefault("created_at", get_formatted_time())
  181. node_data.setdefault("updated_at", node_data["created_at"])
  182. immutable = {
  183. key: node_data[key]
  184. for key in ("uid", "name_zh", "script_type", "script_requirement")
  185. }
  186. driver = connect_graph()
  187. try:
  188. with driver.session() as session:
  189. DataFlowService._install_dataflow_constraints(session)
  190. conflict = session.run(
  191. "MATCH (n:DataFlow {name_zh: $name_zh}) "
  192. "WHERE n.uid IS NULL OR n.uid <> $uid "
  193. "RETURN n.uid AS uid LIMIT 1",
  194. {"name_zh": node_data["name_zh"], "uid": node_data["uid"]},
  195. ).single()
  196. if conflict is not None:
  197. raise ValueError("dataflow_uid_conflict")
  198. try:
  199. record = session.run(
  200. "MERGE (n:DataFlow {uid: $uid}) "
  201. "ON CREATE SET n = $properties "
  202. "RETURN n, id(n) AS node_id",
  203. {"uid": node_data["uid"], "properties": node_data},
  204. ).single()
  205. except Exception as exc:
  206. if DataFlowService._is_dataflow_constraint_conflict(exc):
  207. raise ValueError("dataflow_uid_conflict") from exc
  208. raise
  209. if record is None:
  210. raise RuntimeError(
  211. "governed DataFlow MERGE returned no node"
  212. )
  213. persisted = dict(record["n"])
  214. for key, expected in immutable.items():
  215. if persisted.get(key) != expected:
  216. raise ValueError("dataflow_uid_conflict")
  217. node_id = record["node_id"]
  218. if isinstance(node_id, bool) or not isinstance(node_id, int):
  219. raise RuntimeError("governed DataFlow node id is invalid")
  220. result = dict(persisted)
  221. result["id"] = node_id
  222. return node_id, result
  223. finally:
  224. driver.close()
  225. @classmethod
  226. def validate_governed_create_intent(
  227. cls, value: Any, *, repository
  228. ) -> dict[str, Any]:
  229. if not isinstance(value, dict) or set(value) != {
  230. "dataflow_uid",
  231. "node",
  232. "tags",
  233. }:
  234. raise ValueError("dataflow create intent is invalid")
  235. node = value.get("node")
  236. tags = value.get("tags")
  237. if not isinstance(node, dict) or set(node) != cls._GOVERNED_NODE_KEYS:
  238. raise ValueError("dataflow create intent node is invalid")
  239. if not isinstance(tags, list) or len(tags) > 100:
  240. raise ValueError("dataflow create intent tags are invalid")
  241. flow_uid = ensure_governance_uid(
  242. {"uid": str(value.get("dataflow_uid"))}
  243. )
  244. if node.get("uid") != flow_uid:
  245. raise ValueError("dataflow create intent UID mismatch")
  246. if node.get("script_type") != "governed" or node.get(
  247. "script_path"
  248. ) not in {"", None}:
  249. raise ValueError("dataflow create intent execution mode is invalid")
  250. requirement = cls.validate_governed_requirement(
  251. node.get("script_requirement"), repository=repository
  252. )
  253. if requirement["dataflow_spec"]["dataflow_uid"] != flow_uid:
  254. raise ValueError("dataflow create intent requirement UID mismatch")
  255. normalized_node = copy.deepcopy(node)
  256. normalized_node["script_requirement"] = json.dumps(
  257. requirement,
  258. ensure_ascii=False,
  259. sort_keys=True,
  260. separators=(",", ":"),
  261. )
  262. for key in cls._GOVERNED_NODE_KEYS - {
  263. "script_path",
  264. "script_requirement",
  265. }:
  266. if not isinstance(normalized_node.get(key), str):
  267. raise ValueError("dataflow create intent node field is invalid")
  268. return {
  269. "dataflow_uid": flow_uid,
  270. "node": normalized_node,
  271. "tags": copy.deepcopy(tags),
  272. }
  273. @staticmethod
  274. def get_dataflows(
  275. page: int = 1,
  276. page_size: int = 10,
  277. search: str = "",
  278. ) -> Dict[str, Any]:
  279. """
  280. 获取数据流列表
  281. Args:
  282. page: 页码
  283. page_size: 每页大小
  284. search: 搜索关键词
  285. Returns:
  286. 包含数据流列表和分页信息的字典
  287. """
  288. try:
  289. # 从图数据库查询数据流列表
  290. skip_count = (page - 1) * page_size
  291. # 构建搜索条件
  292. where_clause = ""
  293. params: Dict[str, Union[int, str]] = {
  294. "skip": skip_count,
  295. "limit": page_size,
  296. }
  297. if search:
  298. where_clause = (
  299. "WHERE n.name_zh CONTAINS $search OR n.description CONTAINS $search"
  300. )
  301. params["search"] = search
  302. # 查询数据流列表(包含标签数组)
  303. # 使用WITH子句先分页,再聚合标签,避免分页结果不准确
  304. query = f"""
  305. MATCH (n:DataFlow)
  306. {where_clause}
  307. WITH n
  308. ORDER BY n.created_at DESC
  309. SKIP $skip
  310. LIMIT $limit
  311. OPTIONAL MATCH (n)-[:LABEL]->(label:DataLabel)
  312. RETURN n, id(n) as node_id,
  313. n.created_at as created_at,
  314. collect({{
  315. id: id(label),
  316. name_zh: label.name_zh,
  317. name_en: label.name_en
  318. }}) as tags
  319. """
  320. # 获取Neo4j驱动(如果连接失败会抛出ConnectionError异常)
  321. try:
  322. with connect_graph().session() as session:
  323. list_result = session.run(query, params).data()
  324. # 查询总数
  325. count_query = f"""
  326. MATCH (n:DataFlow)
  327. {where_clause}
  328. RETURN count(n) as total
  329. """
  330. count_params = {"search": search} if search else {}
  331. count_result = session.run(count_query, count_params).single()
  332. total = count_result["total"] if count_result else 0
  333. except Exception as e:
  334. # 确保 driver 被正确关闭,避免资源泄漏 - 这里不再需要手动关闭
  335. # driver,因为connect_graph可能返回单例或新实例。如果是新实例,
  336. # 我们没有引用它去关闭;若connect_graph每次返回新实例且需要关闭,
  337. # 之前的代码是对的。如果connect_graph返回单例,则不应关闭。
  338. # 用户反馈:The driver.close() call prematurely closes a shared
  339. # driver instance,所以直接使用 session,并不关闭 driver。
  340. logger.error(f"查询数据流失败: {str(e)}")
  341. raise e
  342. # 格式化结果
  343. dataflows = []
  344. for record in list_result:
  345. node = record["n"]
  346. dataflow = dict(node)
  347. dataflow["id"] = record["node_id"] # 使用查询返回的node_id
  348. # 处理标签数组,过滤掉空标签
  349. tags = record.get("tags", [])
  350. dataflow["tag"] = [tag for tag in tags if tag.get("id") is not None]
  351. dataflows.append(dataflow)
  352. return {
  353. "list": dataflows,
  354. "pagination": {
  355. "page": page,
  356. "page_size": page_size,
  357. "total": total,
  358. "total_pages": (total + page_size - 1) // page_size,
  359. },
  360. }
  361. except Exception as e:
  362. logger.error(f"获取数据流列表失败: {str(e)}")
  363. raise e
  364. @staticmethod
  365. def get_dataflow_by_id(dataflow_id: int) -> Optional[Dict[str, Any]]:
  366. """
  367. 根据ID获取数据流详情
  368. Args:
  369. dataflow_id: 数据流ID
  370. Returns:
  371. 数据流详情字典,如果不存在则返回None
  372. """
  373. try:
  374. # 从Neo4j获取DataFlow节点的所有属性(包含标签数组)
  375. neo4j_query = """
  376. MATCH (n:DataFlow)
  377. WHERE id(n) = $dataflow_id
  378. OPTIONAL MATCH (n)-[:LABEL]->(label:DataLabel)
  379. RETURN n, id(n) as node_id,
  380. collect({
  381. id: id(label),
  382. name_zh: label.name_zh,
  383. name_en: label.name_en
  384. }) as tags
  385. """
  386. with connect_graph().session() as session:
  387. neo4j_result = session.run(neo4j_query, dataflow_id=dataflow_id).data()
  388. if not neo4j_result:
  389. logger.warning(f"未找到ID为 {dataflow_id} 的DataFlow节点")
  390. return None
  391. record = neo4j_result[0]
  392. node = record["n"]
  393. # 将节点属性转换为字典
  394. dataflow = dict(node)
  395. dataflow["id"] = record["node_id"]
  396. # 处理标签数组,过滤掉空标签
  397. tags = record.get("tags", [])
  398. dataflow["tag"] = [tag for tag in tags if tag.get("id") is not None]
  399. # 处理 script_requirement:如果是JSON字符串,解析为对象
  400. script_requirement_str = dataflow.get("script_requirement", "")
  401. if script_requirement_str:
  402. try:
  403. # 尝试解析JSON字符串
  404. script_requirement_obj = json.loads(script_requirement_str)
  405. dataflow["script_requirement"] = script_requirement_obj
  406. logger.debug(
  407. "成功解析script_requirement: %s",
  408. script_requirement_obj,
  409. )
  410. except (json.JSONDecodeError, TypeError) as e:
  411. logger.warning(f"script_requirement解析失败,保持原值: {e}")
  412. # 保持原值(字符串)
  413. dataflow["script_requirement"] = script_requirement_str
  414. else:
  415. # 如果为空,设置为None
  416. dataflow["script_requirement"] = None
  417. logger.info(
  418. "成功获取DataFlow详情,ID: %s, 名称: %s",
  419. dataflow_id,
  420. dataflow.get("name_zh"),
  421. )
  422. return dataflow
  423. except Exception as e:
  424. logger.error(f"获取数据流详情失败: {str(e)}")
  425. raise e
  426. @staticmethod
  427. def create_dataflow(
  428. data: Dict[str, Any], *, repository=None, actor_uid=None
  429. ) -> Dict[str, Any]:
  430. """
  431. 创建新的数据流
  432. Args:
  433. data: 数据流配置数据
  434. Returns:
  435. 创建的数据流信息
  436. """
  437. try:
  438. # 验证必填字段
  439. required_fields = ["name_zh", "describe"]
  440. for field in required_fields:
  441. if field not in data:
  442. raise ValueError(f"缺少必填字段: {field}")
  443. dataflow_name = data["name_zh"]
  444. script_requirement = data.get("script_requirement")
  445. decoded_requirement = DataFlowService._decode_script_requirement(
  446. script_requirement
  447. )
  448. governed = DataFlowService._signals_governed(
  449. data, decoded_requirement
  450. )
  451. name_en = data.get("name_en")
  452. if governed and (
  453. not isinstance(name_en, str) or not name_en.strip()
  454. ):
  455. flow_value = (
  456. decoded_requirement.get("dataflow_spec")
  457. if isinstance(decoded_requirement, dict)
  458. else None
  459. )
  460. flow_uid = (
  461. flow_value.get("dataflow_uid")
  462. if isinstance(flow_value, dict)
  463. else None
  464. )
  465. name_en = f"dataflow_{str(flow_uid).replace('-', '')}"
  466. elif not isinstance(name_en, str) or not name_en.strip():
  467. # 使用LLM翻译名称生成英文名
  468. try:
  469. result_list = translate_and_parse(dataflow_name)
  470. name_en = (
  471. result_list[0]
  472. if result_list
  473. else dataflow_name.lower().replace(" ", "_")
  474. )
  475. except Exception as e:
  476. logger.warning(f"翻译失败,使用默认英文名: {str(e)}")
  477. name_en = dataflow_name.lower().replace(" ", "_")
  478. name_en = name_en.strip()
  479. saga_claim = None
  480. receipt = None
  481. if governed:
  482. DataFlowService._reject_governed_execution_fields(data)
  483. if repository is None:
  484. from app.core.data_rules.repository import DataRuleRepository
  485. repository = DataRuleRepository(db.session)
  486. decoded_requirement = (
  487. DataFlowService.validate_governed_requirement(
  488. decoded_requirement, repository=repository
  489. )
  490. )
  491. if actor_uid is None:
  492. raise ValueError(
  493. "governed DataFlow requires an authenticated actor"
  494. )
  495. receipt = data.get("draft_reservation")
  496. script_requirement = decoded_requirement
  497. # 处理 script_requirement,将其转换为 JSON 字符串
  498. if script_requirement is not None:
  499. # 如果是字典或列表,转换为 JSON 字符串
  500. if isinstance(script_requirement, (dict, list)):
  501. script_requirement_str = json.dumps(
  502. script_requirement,
  503. ensure_ascii=False,
  504. sort_keys=governed,
  505. separators=(",", ":") if governed else None,
  506. )
  507. else:
  508. # 如果已经是字符串,直接使用
  509. script_requirement_str = str(script_requirement)
  510. else:
  511. script_requirement_str = ""
  512. # 准备节点数据(tag不作为节点属性存储,而是通过LABEL关系关联)
  513. node_data = {
  514. "name_zh": dataflow_name,
  515. "name_en": name_en,
  516. "category": data.get("category", ""),
  517. "organization": data.get("organization", ""),
  518. "leader": data.get("leader", ""),
  519. "frequency": data.get("frequency", ""),
  520. "describe": data.get("describe", ""),
  521. "status": data.get("status", "inactive"),
  522. "update_mode": data.get("update_mode", "append"),
  523. "script_type": data.get("script_type", "python"),
  524. "script_requirement": script_requirement_str,
  525. "script_path": "", # 脚本路径,任务完成后更新
  526. }
  527. if not governed:
  528. node_data["created_at"] = get_formatted_time()
  529. node_data["updated_at"] = get_formatted_time()
  530. if governed:
  531. node_data["uid"] = decoded_requirement["dataflow_spec"][
  532. "dataflow_uid"
  533. ]
  534. node_data["script_type"] = "governed"
  535. ensure_governance_uid(node_data)
  536. # Governed nodes use the reservation UID as the only create key.
  537. if governed:
  538. tag_list = copy.deepcopy(data.get("tag", []))
  539. if not isinstance(tag_list, list):
  540. tag_list = [tag_list] if tag_list else []
  541. create_intent = {
  542. "dataflow_uid": node_data["uid"],
  543. "node": copy.deepcopy(node_data),
  544. "tags": tag_list,
  545. }
  546. request_payload = copy.deepcopy(data)
  547. request_payload.pop("draft_reservation", None)
  548. request_payload["script_requirement"] = decoded_requirement
  549. create_request = {
  550. "payload": request_payload,
  551. "intent": create_intent,
  552. }
  553. saga_claim = repository.begin_dataflow_create(
  554. receipt,
  555. actor_uid=actor_uid,
  556. create_request=create_request,
  557. create_intent=create_intent,
  558. )
  559. if saga_claim["status"] == "completed":
  560. return copy.deepcopy(saga_claim["result"])
  561. reserved_uid = saga_claim["dataflow_uid"]
  562. if reserved_uid != node_data["uid"]:
  563. raise ValueError(
  564. "draft reservation does not match dataflow_uid"
  565. )
  566. stored_intent = DataFlowService.validate_governed_create_intent(
  567. saga_claim["create_intent"], repository=repository
  568. )
  569. node_data = stored_intent["node"]
  570. tag_list = stored_intent["tags"]
  571. repository.commit_dataflow_create_claim()
  572. try:
  573. dataflow_id, result = (
  574. DataFlowService._merge_governed_dataflow(node_data)
  575. )
  576. except Exception as graph_error:
  577. try:
  578. repository.commit_dataflow_create_failure(
  579. reservation_id=receipt["reservation_id"],
  580. lease_token=saga_claim["lease_token"],
  581. error_code="neo4j_create_failed",
  582. )
  583. except Exception:
  584. logger.exception(
  585. "failed to persist governed DataFlow saga failure"
  586. )
  587. raise graph_error
  588. else:
  589. dataflow_id = get_node("DataFlow", name=dataflow_name)
  590. if dataflow_id:
  591. raise ValueError(f"数据流 '{dataflow_name}' 已存在")
  592. dataflow_id = create_or_get_node("DataFlow", **node_data)
  593. # 处理标签关系(支持多标签数组)
  594. tag_list = tag_list if governed else data.get("tag", [])
  595. if tag_list:
  596. try:
  597. DataFlowService._handle_tag_relationships(
  598. dataflow_id, tag_list, strict=governed
  599. )
  600. except Exception as e:
  601. if governed:
  602. try:
  603. repository.commit_dataflow_create_failure(
  604. reservation_id=receipt["reservation_id"],
  605. lease_token=saga_claim["lease_token"],
  606. error_code="neo4j_tag_merge_failed",
  607. )
  608. except Exception:
  609. logger.exception(
  610. "failed to persist governed DataFlow tag failure"
  611. )
  612. raise
  613. logger.warning(f"处理标签关系时出错: {str(e)}")
  614. # Governed definitions are control-plane assets. Data Factory owns
  615. # all later task, code, workflow and product deployment side effects.
  616. if not governed:
  617. try:
  618. DataFlowService._save_to_pg_database(
  619. data, dataflow_name, name_en
  620. )
  621. logger.info(
  622. f"数据流信息已写入PG数据库: {dataflow_name}"
  623. )
  624. try:
  625. DataFlowService._handle_script_relationships(
  626. data, dataflow_name, name_en
  627. )
  628. logger.info(f"脚本关系创建成功: {dataflow_name}")
  629. except Exception as script_error:
  630. logger.warning(
  631. f"创建脚本关系失败: {str(script_error)}"
  632. )
  633. except Exception as pg_error:
  634. logger.error(f"写入PG数据库失败: {str(pg_error)}")
  635. if governed:
  636. result = repository.complete_dataflow_create(
  637. reservation_id=receipt["reservation_id"],
  638. dataflow_uid=node_data["uid"],
  639. lease_token=saga_claim["lease_token"],
  640. request_digest=saga_claim["request_digest"],
  641. dataflow_node_id=dataflow_id,
  642. result=result,
  643. )
  644. else:
  645. # 查询创建的节点获取完整信息
  646. query = (
  647. "MATCH (n:DataFlow {name_zh: $name_zh}) "
  648. "RETURN n, id(n) as node_id"
  649. )
  650. with connect_graph().session() as session:
  651. id_result = session.run(query, name_zh=dataflow_name).single()
  652. if id_result:
  653. dataflow_node = id_result["n"]
  654. node_id = id_result["node_id"]
  655. result = dict(dataflow_node)
  656. result["id"] = node_id
  657. else:
  658. result = {
  659. "id": (
  660. dataflow_id
  661. if isinstance(dataflow_id, int)
  662. else None
  663. ),
  664. "uid": node_data["uid"],
  665. "name_zh": dataflow_name,
  666. "name_en": name_en,
  667. "created_at": get_formatted_time(),
  668. }
  669. if not governed:
  670. try:
  671. DataFlowService._register_data_product(
  672. data=data,
  673. dataflow_name=dataflow_name,
  674. name_en=name_en,
  675. dataflow_id=result.get("id"),
  676. )
  677. logger.info(f"数据产品注册成功: {dataflow_name}")
  678. except Exception as product_error:
  679. logger.warning(f"注册数据产品失败: {str(product_error)}")
  680. logger.info(f"创建数据流成功: {dataflow_name}")
  681. return result
  682. except Exception as e:
  683. logger.error(f"创建数据流失败: {str(e)}")
  684. raise e
  685. @staticmethod
  686. def _save_to_pg_database(
  687. data: Dict[str, Any],
  688. script_name: str,
  689. name_en: str,
  690. ):
  691. """
  692. 将任务信息保存到PG数据库的task_list表
  693. Args:
  694. data: 包含脚本信息的数据
  695. script_name: 脚本名称
  696. name_en: 英文名称
  697. """
  698. try:
  699. # 提取脚本相关信息
  700. # 处理 script_requirement,确保保存为 JSON 字符串
  701. script_requirement_raw = data.get("script_requirement")
  702. if script_requirement_raw is not None:
  703. if isinstance(script_requirement_raw, (dict, list)):
  704. script_requirement = json.dumps(
  705. script_requirement_raw, ensure_ascii=False
  706. )
  707. else:
  708. script_requirement = str(script_requirement_raw)
  709. else:
  710. script_requirement = ""
  711. # 验证必需字段
  712. if not script_name:
  713. raise ValueError("script_name不能为空")
  714. current_time = datetime.now()
  715. # 保存到task_list表
  716. try:
  717. # 1. 解析script_requirement并构建详细的任务描述
  718. task_description_md = script_requirement
  719. try:
  720. # 尝试解析JSON
  721. try:
  722. req_json = json.loads(script_requirement)
  723. except (json.JSONDecodeError, TypeError):
  724. req_json = None
  725. if isinstance(req_json, dict):
  726. # 1. 从script_requirement中提取rule字段作为request_content_str
  727. request_content_str = req_json.get("rule", "")
  728. # 2. 从script_requirement中提取source_table和
  729. # target_table字段信息
  730. source_table_ids = req_json.get("source_table", [])
  731. target_table_ids = req_json.get("target_table", [])
  732. # 确保是列表格式
  733. if not isinstance(source_table_ids, list):
  734. source_table_ids = (
  735. [source_table_ids] if source_table_ids else []
  736. )
  737. if not isinstance(target_table_ids, list):
  738. target_table_ids = (
  739. [target_table_ids] if target_table_ids else []
  740. )
  741. # 从data参数中提取update_mode
  742. update_mode = data.get("update_mode", "append")
  743. # 生成Business Domain DDLs和数据源信息
  744. source_tables_info = []
  745. target_tables_info = []
  746. if source_table_ids or target_table_ids:
  747. try:
  748. with connect_graph().session() as session:
  749. # 处理source tables
  750. for bd_id in source_table_ids:
  751. ddl_info = DataFlowService._generate_businessdomain_ddl(
  752. session,
  753. bd_id,
  754. is_target=False,
  755. )
  756. if ddl_info:
  757. source_tables_info.append(ddl_info)
  758. # 处理target tables(目标表缺省要有create_time字段)
  759. for bd_id in target_table_ids:
  760. ddl_info = DataFlowService._generate_businessdomain_ddl(
  761. session,
  762. bd_id,
  763. is_target=True,
  764. update_mode=update_mode,
  765. )
  766. if ddl_info:
  767. target_tables_info.append(ddl_info)
  768. except Exception as neo_e:
  769. logger.error(
  770. f"获取BusinessDomain DDL失败: {str(neo_e)}"
  771. )
  772. # 构建Markdown格式的任务描述
  773. task_desc_parts = [f"# Task: {script_name}\n"]
  774. # 添加源表信息(DDL和数据源)
  775. if source_tables_info:
  776. task_desc_parts.append("## Source Tables")
  777. for info in source_tables_info:
  778. task_desc_parts.append(f"### {info['table_name']}")
  779. if info.get("data_source"):
  780. ds = info["data_source"]
  781. task_desc_parts.append("**Data Source**")
  782. task_desc_parts.append(
  783. f"- **Type**: {ds.get('type', 'N/A')}"
  784. )
  785. task_desc_parts.append(
  786. f"- **Host**: {ds.get('host', 'N/A')}"
  787. )
  788. task_desc_parts.append(
  789. f"- **Port**: {ds.get('port', 'N/A')}"
  790. )
  791. task_desc_parts.append(
  792. f"- **Database**: {ds.get('database', 'N/A')}"
  793. )
  794. task_desc_parts.append(
  795. f"- **Schema**: {ds.get('schema', 'N/A')}\n"
  796. )
  797. task_desc_parts.append("**DDL**")
  798. task_desc_parts.append(f"```sql\n{info['ddl']}\n```\n")
  799. # 添加目标表信息(DDL和数据源)
  800. if target_tables_info:
  801. task_desc_parts.append("## Target Tables")
  802. for info in target_tables_info:
  803. task_desc_parts.append(f"### {info['table_name']}")
  804. if info.get("data_source"):
  805. ds = info["data_source"]
  806. task_desc_parts.append("**Data Source**")
  807. task_desc_parts.append(
  808. f"- **Type**: {ds.get('type', 'N/A')}"
  809. )
  810. task_desc_parts.append(
  811. f"- **Host**: {ds.get('host', 'N/A')}"
  812. )
  813. task_desc_parts.append(
  814. f"- **Port**: {ds.get('port', 'N/A')}"
  815. )
  816. task_desc_parts.append(
  817. f"- **Database**: {ds.get('database', 'N/A')}"
  818. )
  819. task_desc_parts.append(
  820. f"- **Schema**: {ds.get('schema', 'N/A')}\n"
  821. )
  822. task_desc_parts.append("**DDL**")
  823. task_desc_parts.append(f"```sql\n{info['ddl']}\n```\n")
  824. # 添加更新模式说明
  825. task_desc_parts.append("## Update Mode")
  826. if update_mode == "append":
  827. task_desc_parts.append("- **Mode**: Append (追加模式)")
  828. task_desc_parts.append(
  829. "- **Description**: 新数据将追加到目标表,不删除现有数据\n"
  830. )
  831. else:
  832. task_desc_parts.append(
  833. "- **Mode**: Full Refresh (全量更新)"
  834. )
  835. task_desc_parts.append(
  836. "- **Description**: 目标表将被清空后重新写入数据\n"
  837. )
  838. # 添加请求内容(rule)
  839. if request_content_str:
  840. task_desc_parts.append("## Request Content")
  841. task_desc_parts.append(f"{request_content_str}\n")
  842. # 添加实施步骤(统一使用数据转换任务步骤)
  843. task_desc_parts.append("## Implementation Steps")
  844. task_desc_parts.append(
  845. "1. Extract data from source tables as specified in the DDL"
  846. )
  847. task_desc_parts.append(
  848. "2. Apply transformation logic according to the rule:"
  849. )
  850. if request_content_str:
  851. task_desc_parts.append(f" - Rule: {request_content_str}")
  852. task_desc_parts.append(
  853. "3. Generate Python program to implement the "
  854. "data transformation logic"
  855. )
  856. task_desc_parts.append(
  857. f"4. Write transformed data to target table "
  858. f"using {update_mode} mode"
  859. )
  860. task_description_md = "\n".join(task_desc_parts)
  861. except Exception as parse_e:
  862. logger.warning(
  863. f"解析任务描述详情失败,使用原始描述: {str(parse_e)}"
  864. )
  865. task_description_md = script_requirement
  866. # 设置 code_path(不包含文件名)
  867. # code_name 需要在获取 task_id 后生成
  868. code_path = "datafactory/scripts"
  869. task_insert_sql = text(
  870. "INSERT INTO public.task_list\n"
  871. "(task_name, task_description, status, code_name, "
  872. "code_path, create_by, create_time, update_time)\n"
  873. "VALUES\n"
  874. "(:task_name, :task_description, :status, :code_name, "
  875. ":code_path, :create_by, :create_time, :update_time)\n"
  876. "RETURNING task_id"
  877. )
  878. task_params = {
  879. "task_name": script_name,
  880. "task_description": task_description_md,
  881. "status": "pending",
  882. "code_name": "", # 暂时为空,等获取 task_id 后更新
  883. "code_path": code_path,
  884. "create_by": "cursor",
  885. "create_time": current_time,
  886. "update_time": current_time,
  887. }
  888. result = db.session.execute(task_insert_sql, task_params)
  889. row = result.fetchone()
  890. task_id = row[0] if row else None
  891. # 根据 task_id 生成脚本文件名
  892. # 格式: task_{task_id}_{task_name}.py(与 auto_execute_tasks 生成的一致)
  893. code_name = f"task_{task_id}_{script_name}.py"
  894. # 更新 code_name 字段
  895. if task_id:
  896. update_sql = text(
  897. "UPDATE public.task_list SET code_name = :code_name "
  898. "WHERE task_id = :task_id"
  899. )
  900. db.session.execute(
  901. update_sql, {"code_name": code_name, "task_id": task_id}
  902. )
  903. db.session.commit()
  904. logger.info(
  905. f"成功将任务信息写入task_list表: "
  906. f"task_id={task_id}, task_name={script_name}, code_name={code_name}"
  907. )
  908. # 自动生成 n8n 工作流 JSON 文件
  909. try:
  910. DataFlowService._generate_n8n_workflow(
  911. script_name=script_name,
  912. code_name=code_name,
  913. code_path=code_path,
  914. update_mode=update_mode,
  915. task_id=task_id,
  916. )
  917. except Exception as wf_error:
  918. logger.warning(f"生成n8n工作流文件失败: {str(wf_error)}")
  919. # 不影响主流程
  920. except Exception as task_error:
  921. db.session.rollback()
  922. logger.error(f"写入task_list表失败: {str(task_error)}")
  923. raise task_error
  924. except Exception as e:
  925. db.session.rollback()
  926. logger.error(f"保存到PG数据库失败: {str(e)}")
  927. raise e
  928. @staticmethod
  929. def _generate_n8n_workflow(
  930. script_name: str,
  931. code_name: str,
  932. code_path: str,
  933. update_mode: str = "append",
  934. task_id: Optional[int] = None,
  935. ) -> Optional[str]:
  936. """
  937. 自动生成 n8n 工作流 JSON 文件
  938. Args:
  939. script_name: 脚本/任务名称
  940. code_name: 代码文件名(如 task_42_DF_DO202601210001.py)
  941. code_path: 代码路径(如 datafactory/scripts)
  942. update_mode: 更新模式
  943. task_id: 关联的任务 ID
  944. Returns:
  945. 生成的工作流文件路径,失败返回 None
  946. """
  947. try:
  948. # 确保工作流目录存在
  949. workflows_dir = PROJECT_ROOT / "datafactory" / "workflows"
  950. workflows_dir.mkdir(parents=True, exist_ok=True)
  951. # 生成工作流文件名(使用任务ID以便于关联)
  952. if task_id:
  953. workflow_filename = f"task_{task_id}_{script_name}_workflow.json"
  954. else:
  955. workflow_filename = f"{script_name}_workflow.json"
  956. workflow_path = workflows_dir / workflow_filename
  957. # 生成唯一ID
  958. def gen_id():
  959. return str(uuid.uuid4())
  960. # 构建完整的 SSH 命令,包含激活 venv
  961. # 注意:由于 n8n 服务器与应用服务器分离,必须使用 SSH 节点
  962. # code_name 已经包含 .py 后缀(如 task_42_DF_DO202601210001.py)
  963. ssh_command = (
  964. f"cd /opt/dataops-platform && source venv/bin/activate && "
  965. f"python {code_path}/{code_name}"
  966. )
  967. workflow_json = {
  968. "name": f"{script_name}_工作流",
  969. "nodes": [
  970. {
  971. "parameters": {
  972. "rule": {
  973. "interval": [
  974. {
  975. "field": "days",
  976. "daysInterval": 1,
  977. "triggerAtHour": 1,
  978. "triggerAtMinute": 0,
  979. }
  980. ]
  981. }
  982. },
  983. "id": gen_id(),
  984. "name": "Schedule Trigger",
  985. "type": "n8n-nodes-base.scheduleTrigger",
  986. "typeVersion": 1.2,
  987. "position": [250, 300],
  988. },
  989. {
  990. "parameters": {
  991. "resource": "command",
  992. "operation": "execute",
  993. "command": ssh_command,
  994. "cwd": "/opt/dataops-platform",
  995. },
  996. "id": gen_id(),
  997. "name": "Execute Script",
  998. "type": "n8n-nodes-base.ssh",
  999. "typeVersion": 1,
  1000. "position": [450, 300],
  1001. "credentials": {
  1002. "sshPassword": {
  1003. "id": "pYTwwuyC15caQe6y",
  1004. "name": "SSH Password account",
  1005. }
  1006. },
  1007. },
  1008. {
  1009. "parameters": {
  1010. "conditions": {
  1011. "options": {
  1012. "caseSensitive": True,
  1013. "leftValue": "",
  1014. "typeValidation": "strict",
  1015. },
  1016. "conditions": [
  1017. {
  1018. "id": "condition-success",
  1019. "leftValue": "={{ $json.code }}",
  1020. "rightValue": 0,
  1021. "operator": {
  1022. "type": "number",
  1023. "operation": "equals",
  1024. },
  1025. }
  1026. ],
  1027. "combinator": "and",
  1028. }
  1029. },
  1030. "id": gen_id(),
  1031. "name": "Check Result",
  1032. "type": "n8n-nodes-base.if",
  1033. "typeVersion": 2,
  1034. "position": [650, 300],
  1035. },
  1036. {
  1037. "parameters": {
  1038. "assignments": {
  1039. "assignments": [
  1040. {
  1041. "id": "result-success",
  1042. "name": "status",
  1043. "value": "success",
  1044. "type": "string",
  1045. },
  1046. {
  1047. "id": "result-message",
  1048. "name": "message",
  1049. "value": f"{script_name} 执行成功",
  1050. "type": "string",
  1051. },
  1052. {
  1053. "id": "result-output",
  1054. "name": "output",
  1055. "value": "={{ $json.stdout }}",
  1056. "type": "string",
  1057. },
  1058. {
  1059. "id": "result-time",
  1060. "name": "executionTime",
  1061. "value": "={{ $now.toISO() }}",
  1062. "type": "string",
  1063. },
  1064. ]
  1065. }
  1066. },
  1067. "id": gen_id(),
  1068. "name": "Success Response",
  1069. "type": "n8n-nodes-base.set",
  1070. "typeVersion": 3.4,
  1071. "position": [850, 200],
  1072. },
  1073. {
  1074. "parameters": {
  1075. "assignments": {
  1076. "assignments": [
  1077. {
  1078. "id": "error-status",
  1079. "name": "status",
  1080. "value": "error",
  1081. "type": "string",
  1082. },
  1083. {
  1084. "id": "error-message",
  1085. "name": "message",
  1086. "value": f"{script_name} 执行失败",
  1087. "type": "string",
  1088. },
  1089. {
  1090. "id": "error-output",
  1091. "name": "error",
  1092. "value": "={{ $json.stderr }}",
  1093. "type": "string",
  1094. },
  1095. {
  1096. "id": "error-code",
  1097. "name": "exitCode",
  1098. "value": "={{ $json.code }}",
  1099. "type": "number",
  1100. },
  1101. {
  1102. "id": "error-time",
  1103. "name": "executionTime",
  1104. "value": "={{ $now.toISO() }}",
  1105. "type": "string",
  1106. },
  1107. ]
  1108. }
  1109. },
  1110. "id": gen_id(),
  1111. "name": "Error Response",
  1112. "type": "n8n-nodes-base.set",
  1113. "typeVersion": 3.4,
  1114. "position": [850, 400],
  1115. },
  1116. ],
  1117. "connections": {
  1118. "Schedule Trigger": {
  1119. "main": [
  1120. [
  1121. {
  1122. "node": "Execute Script",
  1123. "type": "main",
  1124. "index": 0,
  1125. }
  1126. ]
  1127. ]
  1128. },
  1129. "Execute Script": {
  1130. "main": [
  1131. [
  1132. {
  1133. "node": "Check Result",
  1134. "type": "main",
  1135. "index": 0,
  1136. }
  1137. ]
  1138. ]
  1139. },
  1140. "Check Result": {
  1141. "main": [
  1142. [
  1143. {
  1144. "node": "Success Response",
  1145. "type": "main",
  1146. "index": 0,
  1147. }
  1148. ],
  1149. [
  1150. {
  1151. "node": "Error Response",
  1152. "type": "main",
  1153. "index": 0,
  1154. }
  1155. ],
  1156. ]
  1157. },
  1158. },
  1159. "active": False,
  1160. "settings": {"executionOrder": "v1"},
  1161. "versionId": "1",
  1162. "meta": {
  1163. "templateCredsSetupCompleted": False,
  1164. "instanceId": "dataops-platform",
  1165. },
  1166. "tags": [
  1167. {
  1168. "createdAt": datetime.now().isoformat() + "Z",
  1169. "updatedAt": datetime.now().isoformat() + "Z",
  1170. "id": "1",
  1171. "name": "数据流程",
  1172. }
  1173. ],
  1174. }
  1175. # 写入文件
  1176. with open(workflow_path, "w", encoding="utf-8") as f:
  1177. json.dump(workflow_json, f, ensure_ascii=False, indent=2)
  1178. logger.info(f"成功生成n8n工作流文件: {workflow_path}")
  1179. return str(workflow_path)
  1180. except Exception as e:
  1181. logger.error(f"生成n8n工作流失败: {str(e)}")
  1182. return None
  1183. @staticmethod
  1184. def _handle_children_relationships(dataflow_node, children_ids):
  1185. """处理子节点关系"""
  1186. logger.debug(
  1187. "处理子节点关系,原始children_ids: %s, 类型: %s",
  1188. children_ids,
  1189. type(children_ids),
  1190. )
  1191. # 确保children_ids是列表格式
  1192. if not isinstance(children_ids, (list, tuple)):
  1193. if children_ids is not None:
  1194. children_ids = [children_ids] # 如果是单个值,转换为列表
  1195. logger.debug(f"将单个值转换为列表: {children_ids}")
  1196. else:
  1197. children_ids = [] # 如果是None,转换为空列表
  1198. logger.debug("将None转换为空列表")
  1199. for child_id in children_ids:
  1200. try:
  1201. # 查找子节点
  1202. query = "MATCH (n) WHERE id(n) = $child_id RETURN n"
  1203. with connect_graph().session() as session:
  1204. result = session.run(query, child_id=child_id).data()
  1205. if result:
  1206. # 获取dataflow_node的ID
  1207. dataflow_id = getattr(dataflow_node, "identity", None)
  1208. if dataflow_id is None:
  1209. # 如果没有identity属性,从名称查询ID
  1210. query_id = (
  1211. "MATCH (n:DataFlow) WHERE n.name_zh = "
  1212. "$name_zh RETURN id(n) as node_id"
  1213. )
  1214. id_result = session.run(
  1215. query_id,
  1216. name_zh=dataflow_node.get("name_zh"),
  1217. ).single()
  1218. dataflow_id = id_result["node_id"] if id_result else None
  1219. # 创建关系 - 使用ID调用relationship_exists
  1220. if dataflow_id and not relationship_exists(
  1221. dataflow_id, "child", child_id
  1222. ):
  1223. session.run(
  1224. "MATCH (a), (b) WHERE id(a) = $dataflow_id "
  1225. "AND id(b) = $child_id "
  1226. "CREATE (a)-[:child]->(b)",
  1227. dataflow_id=dataflow_id,
  1228. child_id=child_id,
  1229. )
  1230. logger.info(f"创建子节点关系: {dataflow_id} -> {child_id}")
  1231. except Exception as e:
  1232. logger.warning(f"创建子节点关系失败 {child_id}: {str(e)}")
  1233. @staticmethod
  1234. def _handle_tag_relationships(dataflow_id, tag_list, *, strict=False):
  1235. """
  1236. 处理多标签关系
  1237. Args:
  1238. dataflow_id: 数据流节点ID
  1239. tag_list: 标签列表,可以是ID数组或包含id字段的对象数组
  1240. """
  1241. # 确保tag_list是列表格式
  1242. if not isinstance(tag_list, list):
  1243. tag_list = [tag_list] if tag_list else []
  1244. for tag_item in tag_list:
  1245. tag_id = None
  1246. if isinstance(tag_item, dict) and "id" in tag_item:
  1247. tag_id = int(tag_item["id"])
  1248. elif isinstance(tag_item, (int, str)):
  1249. with contextlib.suppress(ValueError, TypeError):
  1250. tag_id = int(tag_item)
  1251. if tag_id:
  1252. DataFlowService._handle_single_tag_relationship(
  1253. dataflow_id, tag_id, strict=strict
  1254. )
  1255. elif strict:
  1256. raise ValueError("dataflow create intent tag is invalid")
  1257. @staticmethod
  1258. def _handle_single_tag_relationship(dataflow_id, tag_id, *, strict=False):
  1259. """处理单个标签关系"""
  1260. driver = None
  1261. try:
  1262. driver = connect_graph()
  1263. with driver.session() as session:
  1264. merged = session.run(
  1265. "MATCH (a:DataFlow), (b:DataLabel) "
  1266. "WHERE id(a) = $dataflow_id AND id(b) = $tag_id "
  1267. "MERGE (a)-[:LABEL]->(b) "
  1268. "RETURN count(*) AS merged",
  1269. dataflow_id=dataflow_id,
  1270. tag_id=tag_id,
  1271. ).single()
  1272. if merged is None or merged["merged"] != 1:
  1273. raise ValueError("dataflow tag target does not exist")
  1274. logger.info(f"合并标签关系: {dataflow_id} -> {tag_id}")
  1275. except Exception as e:
  1276. if strict:
  1277. raise
  1278. logger.warning(f"创建标签关系失败 {tag_id}: {str(e)}")
  1279. finally:
  1280. if driver is not None:
  1281. driver.close()
  1282. @staticmethod
  1283. def update_dataflow_script_path(
  1284. dataflow_name: str,
  1285. script_path: str,
  1286. ) -> bool:
  1287. """
  1288. 更新 DataFlow 节点的脚本路径
  1289. 当任务完成后,将创建的 Python 脚本路径更新到 DataFlow 节点
  1290. Args:
  1291. dataflow_name: 数据流名称(中文名)
  1292. script_path: Python 脚本的完整路径
  1293. Returns:
  1294. 是否更新成功
  1295. """
  1296. try:
  1297. query = """
  1298. MATCH (n:DataFlow {name_zh: $name_zh})
  1299. SET n.script_path = $script_path, n.updated_at = $updated_at
  1300. RETURN n
  1301. """
  1302. with connect_graph().session() as session:
  1303. result = session.run(
  1304. query,
  1305. name_zh=dataflow_name,
  1306. script_path=script_path,
  1307. updated_at=get_formatted_time(),
  1308. ).single()
  1309. if result:
  1310. logger.info(
  1311. f"已更新 DataFlow 脚本路径: {dataflow_name} -> {script_path}"
  1312. )
  1313. return True
  1314. else:
  1315. logger.warning(f"未找到 DataFlow 节点: {dataflow_name}")
  1316. return False
  1317. except Exception as e:
  1318. logger.error(f"更新 DataFlow 脚本路径失败: {str(e)}")
  1319. return False
  1320. @staticmethod
  1321. def get_script_content(dataflow_id: int) -> Dict[str, Any]:
  1322. """
  1323. 根据 DataFlow ID 获取关联的脚本内容
  1324. Args:
  1325. dataflow_id: 数据流ID
  1326. Returns:
  1327. 包含脚本内容和元信息的字典:
  1328. - script_path: 脚本路径
  1329. - script_content: 脚本内容
  1330. - script_type: 脚本类型(如 python)
  1331. - dataflow_name: 数据流名称
  1332. Raises:
  1333. ValueError: 当 DataFlow 不存在或脚本路径为空时
  1334. FileNotFoundError: 当脚本文件不存在时
  1335. """
  1336. from pathlib import Path
  1337. try:
  1338. # 从 Neo4j 获取 DataFlow 节点
  1339. query = """
  1340. MATCH (n:DataFlow)
  1341. WHERE id(n) = $dataflow_id
  1342. RETURN n, id(n) as node_id
  1343. """
  1344. with connect_graph().session() as session:
  1345. result = session.run(query, dataflow_id=dataflow_id).single()
  1346. if not result:
  1347. raise ValueError(f"未找到 ID 为 {dataflow_id} 的 DataFlow 节点")
  1348. node = result["n"]
  1349. node_props = dict(node)
  1350. # 获取脚本路径
  1351. script_path = node_props.get("script_path", "")
  1352. if not script_path:
  1353. raise ValueError(
  1354. f"DataFlow (ID: {dataflow_id}) 的 script_path 属性为空"
  1355. )
  1356. # 确定脚本文件的完整路径
  1357. # script_path 可能是相对路径或绝对路径
  1358. script_file = Path(script_path)
  1359. # 如果是相对路径,相对于项目根目录
  1360. if not script_file.is_absolute():
  1361. # 获取项目根目录(假设 app 目录的父目录是项目根)
  1362. project_root = Path(__file__).parent.parent.parent.parent
  1363. script_file = project_root / script_path
  1364. # 检查文件是否存在
  1365. if not script_file.exists():
  1366. raise FileNotFoundError(f"脚本文件不存在: {script_file}")
  1367. # 读取脚本内容
  1368. with script_file.open("r", encoding="utf-8") as f:
  1369. script_content = f.read()
  1370. # 确定脚本类型
  1371. suffix = script_file.suffix.lower()
  1372. script_type_map = {
  1373. ".py": "python",
  1374. ".js": "javascript",
  1375. ".ts": "typescript",
  1376. ".sql": "sql",
  1377. ".sh": "shell",
  1378. }
  1379. script_type = script_type_map.get(suffix, "text")
  1380. logger.info(
  1381. f"成功读取脚本内容: DataFlow ID={dataflow_id}, "
  1382. f"路径={script_path}, 类型={script_type}"
  1383. )
  1384. return {
  1385. "script_path": script_path,
  1386. "script_content": script_content,
  1387. "script_type": script_type,
  1388. "dataflow_id": dataflow_id,
  1389. "dataflow_name": node_props.get("name_zh", ""),
  1390. "dataflow_name_en": node_props.get("name_en", ""),
  1391. }
  1392. except (ValueError, FileNotFoundError):
  1393. raise
  1394. except Exception as e:
  1395. logger.error(f"获取脚本内容失败: {str(e)}")
  1396. raise
  1397. @staticmethod
  1398. def update_dataflow(
  1399. dataflow_id: int,
  1400. data: Dict[str, Any],
  1401. *,
  1402. repository=None,
  1403. ) -> Optional[Dict[str, Any]]:
  1404. """
  1405. 更新数据流
  1406. Args:
  1407. dataflow_id: 数据流ID
  1408. data: 更新的数据
  1409. Returns:
  1410. 更新后的数据流信息,如果不存在则返回None
  1411. """
  1412. try:
  1413. data = copy.deepcopy(data)
  1414. requirement = DataFlowService._decode_script_requirement(
  1415. data.get("script_requirement")
  1416. )
  1417. # 查找节点
  1418. query = "MATCH (n:DataFlow) WHERE id(n) = $dataflow_id RETURN n"
  1419. with connect_graph().session() as session:
  1420. result = session.run(query, dataflow_id=dataflow_id).data()
  1421. if not result:
  1422. return None
  1423. existing = dict(result[0]["n"])
  1424. existing_requirement = (
  1425. DataFlowService._decode_script_requirement(
  1426. existing.get("script_requirement")
  1427. )
  1428. )
  1429. governed = DataFlowService._signals_governed(
  1430. data, requirement
  1431. ) or DataFlowService._signals_governed(
  1432. existing, existing_requirement
  1433. )
  1434. if governed:
  1435. if "draft_reservation" in data:
  1436. raise ValueError(
  1437. "existing DataFlow cannot consume a new draft"
  1438. )
  1439. DataFlowService._reject_governed_execution_fields(data)
  1440. if repository is None:
  1441. from app.core.data_rules.repository import (
  1442. DataRuleRepository,
  1443. )
  1444. repository = DataRuleRepository(db.session)
  1445. requirement = (
  1446. DataFlowService.validate_governed_requirement(
  1447. requirement, repository=repository
  1448. )
  1449. )
  1450. data["script_requirement"] = requirement
  1451. data["script_type"] = "governed"
  1452. data["script_path"] = ""
  1453. data["uid"] = requirement["dataflow_spec"][
  1454. "dataflow_uid"
  1455. ]
  1456. existing_uid = existing.get("uid")
  1457. requested_uid = data["uid"]
  1458. if not existing_uid or str(existing_uid) != requested_uid:
  1459. raise ValueError(
  1460. "dataflow_uid cannot replace an existing identity"
  1461. )
  1462. # 提取 tag 数组(不作为节点属性存储)
  1463. tag_list = data.pop("tag", None)
  1464. # 更新节点属性
  1465. update_fields = []
  1466. params: Dict[str, Any] = {"dataflow_id": dataflow_id}
  1467. for key, value in data.items():
  1468. if key not in ["id", "created_at"]: # 保护字段
  1469. # 复杂对象序列化为 JSON 字符串
  1470. if key in ["config", "script_requirement"] and isinstance(
  1471. value, dict
  1472. ):
  1473. value = json.dumps(value, ensure_ascii=False)
  1474. update_fields.append(f"n.{key} = ${key}")
  1475. params[key] = value
  1476. if update_fields:
  1477. params["updated_at"] = get_formatted_time()
  1478. update_fields.append("n.updated_at = $updated_at")
  1479. update_query = f"""
  1480. MATCH (n:DataFlow) WHERE id(n) = $dataflow_id
  1481. SET {", ".join(update_fields)}
  1482. RETURN n, id(n) as node_id
  1483. """
  1484. result = session.run(update_query, params).data()
  1485. # 处理 tag 关系(支持多标签数组)
  1486. if tag_list is not None:
  1487. # 确保是列表格式
  1488. if not isinstance(tag_list, list):
  1489. tag_list = [tag_list] if tag_list else []
  1490. # 先删除现有的 LABEL 关系
  1491. delete_query = """
  1492. MATCH (n:DataFlow)-[r:LABEL]->(:DataLabel)
  1493. WHERE id(n) = $dataflow_id
  1494. DELETE r
  1495. """
  1496. session.run(delete_query, dataflow_id=dataflow_id)
  1497. logger.info(f"删除数据流 {dataflow_id} 的现有标签关系")
  1498. # 为每个 tag 创建新的 LABEL 关系
  1499. for tag_item in tag_list:
  1500. tag_id = None
  1501. if isinstance(tag_item, dict) and "id" in tag_item:
  1502. tag_id = int(tag_item["id"])
  1503. elif isinstance(tag_item, (int, str)):
  1504. with contextlib.suppress(ValueError, TypeError):
  1505. tag_id = int(tag_item)
  1506. if tag_id:
  1507. DataFlowService._handle_single_tag_relationship(
  1508. dataflow_id, tag_id
  1509. )
  1510. if result:
  1511. node = result[0]["n"]
  1512. updated_dataflow = dict(node)
  1513. # 使用查询返回的node_id
  1514. updated_dataflow["id"] = result[0]["node_id"]
  1515. # 查询并添加标签数组到返回数据
  1516. tags_query = """
  1517. MATCH (n:DataFlow)
  1518. WHERE id(n) = $dataflow_id
  1519. OPTIONAL MATCH (n)-[:LABEL]->(label:DataLabel)
  1520. RETURN collect({
  1521. id: id(label),
  1522. name_zh: label.name_zh,
  1523. name_en: label.name_en
  1524. }) as tags
  1525. """
  1526. tags_result = session.run(
  1527. tags_query, dataflow_id=dataflow_id
  1528. ).single()
  1529. if tags_result:
  1530. tags = tags_result.get("tags", [])
  1531. updated_dataflow["tag"] = [
  1532. tag for tag in tags if tag.get("id") is not None
  1533. ]
  1534. else:
  1535. updated_dataflow["tag"] = []
  1536. logger.info(f"更新数据流成功: ID={dataflow_id}")
  1537. return updated_dataflow
  1538. return None
  1539. except Exception as e:
  1540. logger.error(f"更新数据流失败: {str(e)}")
  1541. raise e
  1542. @staticmethod
  1543. def delete_dataflow(dataflow_id: int) -> bool:
  1544. """
  1545. 删除数据流
  1546. Args:
  1547. dataflow_id: 数据流ID
  1548. Returns:
  1549. 删除是否成功
  1550. """
  1551. try:
  1552. # 删除节点及其关系
  1553. query = """
  1554. MATCH (n:DataFlow) WHERE id(n) = $dataflow_id
  1555. DETACH DELETE n
  1556. RETURN count(n) as deleted_count
  1557. """
  1558. with connect_graph().session() as session:
  1559. delete_result = session.run(query, dataflow_id=dataflow_id).single()
  1560. result = delete_result["deleted_count"] if delete_result else 0
  1561. if result and result > 0:
  1562. logger.info(f"删除数据流成功: ID={dataflow_id}")
  1563. return True
  1564. return False
  1565. except Exception as e:
  1566. logger.error(f"删除数据流失败: {str(e)}")
  1567. raise e
  1568. # 无 COME_FROM 关系时使用显式环境配置;默认仅指向本机,禁止回退内网地址。
  1569. DEFAULT_DATA_SOURCE = {
  1570. "type": "postgresql",
  1571. "host": os.environ.get("DATAFLOW_DEFAULT_DB_HOST", "127.0.0.1"),
  1572. "port": int(os.environ.get("DATAFLOW_DEFAULT_DB_PORT", "5432")),
  1573. "database": os.environ.get("DATAFLOW_DEFAULT_DB_NAME", "dataops"),
  1574. "schema": os.environ.get("DATAFLOW_SCHEMA", "dags"),
  1575. }
  1576. @staticmethod
  1577. def _generate_businessdomain_ddl(
  1578. session,
  1579. bd_id: int,
  1580. is_target: bool = False,
  1581. update_mode: str = "append",
  1582. ) -> Optional[Dict[str, Any]]:
  1583. """
  1584. 根据BusinessDomain节点ID生成DDL
  1585. Args:
  1586. session: Neo4j session对象
  1587. bd_id: BusinessDomain节点ID
  1588. is_target: 是否为目标表(目标表需要添加create_time字段)
  1589. update_mode: 更新模式(append或full)
  1590. Returns:
  1591. 包含ddl和data_source信息的字典,如果节点不存在则返回None
  1592. data_source始终返回,如果没有COME_FROM关系则使用显式环境配置
  1593. """
  1594. try:
  1595. # 查询BusinessDomain节点、元数据、标签关系和数据源关系
  1596. cypher = """
  1597. MATCH (bd:BusinessDomain)
  1598. WHERE id(bd) = $bd_id
  1599. OPTIONAL MATCH (bd)-[:INCLUDES]->(m:DataMeta)
  1600. OPTIONAL MATCH (bd)-[:LABEL]->(label:DataLabel)
  1601. OPTIONAL MATCH (bd)-[:COME_FROM]->(ds:DataSource)
  1602. RETURN bd,
  1603. collect(DISTINCT m) as metadata,
  1604. collect(DISTINCT {
  1605. id: id(label),
  1606. name_zh: label.name_zh,
  1607. name_en: label.name_en
  1608. }) as labels,
  1609. ds.type as ds_type,
  1610. ds.host as ds_host,
  1611. ds.port as ds_port,
  1612. ds.database as ds_database,
  1613. ds.schema as ds_schema
  1614. """
  1615. result = session.run(cypher, bd_id=bd_id).single()
  1616. if not result or not result["bd"]:
  1617. logger.warning(f"未找到ID为 {bd_id} 的BusinessDomain节点")
  1618. return None
  1619. node = result["bd"]
  1620. metadata = result["metadata"]
  1621. # 生成DDL
  1622. node_props = dict(node)
  1623. table_name = node_props.get("name_en", f"table_{bd_id}")
  1624. ddl_lines = []
  1625. ddl_lines.append(f"CREATE TABLE {table_name} (")
  1626. column_definitions = []
  1627. # 添加元数据列
  1628. if metadata:
  1629. for meta in metadata:
  1630. if meta:
  1631. meta_props = dict(meta)
  1632. column_name = meta_props.get(
  1633. "name_en",
  1634. meta_props.get("name_zh", "unknown_column"),
  1635. )
  1636. data_type = meta_props.get("data_type", "VARCHAR(255)")
  1637. comment = meta_props.get("name_zh", "")
  1638. column_def = f" {column_name} {data_type}"
  1639. if comment:
  1640. column_def += f" COMMENT '{comment}'"
  1641. column_definitions.append(column_def)
  1642. # 如果没有元数据,添加默认主键
  1643. if not column_definitions:
  1644. column_definitions.append(" id BIGINT PRIMARY KEY COMMENT '主键ID'")
  1645. # 如果是目标表,添加create_time字段
  1646. if is_target:
  1647. column_definitions.append(
  1648. " create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP "
  1649. "COMMENT '数据创建时间'"
  1650. )
  1651. ddl_lines.append(",\n".join(column_definitions))
  1652. ddl_lines.append(");")
  1653. # 添加表注释
  1654. table_comment = node_props.get(
  1655. "name_zh", node_props.get("describe", table_name)
  1656. )
  1657. if table_comment and table_comment != table_name:
  1658. ddl_lines.append(f"COMMENT ON TABLE {table_name} IS '{table_comment}';")
  1659. ddl_content = "\n".join(ddl_lines)
  1660. # 始终返回数据源信息
  1661. # 如果通过COME_FROM关系找到了数据源,使用该数据源
  1662. # 否则使用默认的生产环境数据库配置
  1663. if result["ds_type"]:
  1664. data_source = {
  1665. "type": result["ds_type"],
  1666. "host": result["ds_host"],
  1667. "port": result["ds_port"],
  1668. "database": result["ds_database"],
  1669. "schema": result["ds_schema"],
  1670. }
  1671. # 端口验证:确保数据库类型使用正确的端口
  1672. # PostgreSQL 默认端口 5432,MySQL 默认端口 3306
  1673. # 5678 是 n8n 服务端口,不是数据库端口
  1674. ds_type_lower = (result["ds_type"] or "").lower()
  1675. current_port = data_source.get("port")
  1676. # 定义数据库类型与默认端口的映射
  1677. db_default_ports = {
  1678. "postgresql": 5432,
  1679. "postgres": 5432,
  1680. "mysql": 3306,
  1681. "mariadb": 3306,
  1682. "sqlserver": 1433,
  1683. "mssql": 1433,
  1684. "oracle": 1521,
  1685. }
  1686. # 常见的非数据库端口(需要修正)
  1687. invalid_db_ports = {5678, 8080, 80, 443, 8000, 3000}
  1688. if ds_type_lower in db_default_ports:
  1689. expected_port = db_default_ports[ds_type_lower]
  1690. if current_port in invalid_db_ports:
  1691. logger.warning(
  1692. f"检测到数据源端口配置异常: type={ds_type_lower}, "
  1693. f"port={current_port}(疑似非数据库端口),"
  1694. f"已自动修正为默认端口 {expected_port}"
  1695. )
  1696. data_source["port"] = expected_port
  1697. elif current_port is None:
  1698. logger.info(f"数据源端口为空,使用默认端口: {expected_port}")
  1699. data_source["port"] = expected_port
  1700. logger.info(f"通过COME_FROM关系获取到数据源信息: {data_source}")
  1701. else:
  1702. # 使用默认生产环境数据源配置
  1703. data_source = DataFlowService.DEFAULT_DATA_SOURCE.copy()
  1704. logger.info(
  1705. f"未找到COME_FROM关系,使用默认生产环境数据源: {data_source}"
  1706. )
  1707. logger.debug(
  1708. f"生成BusinessDomain DDL成功: {table_name}, is_target={is_target}"
  1709. )
  1710. return {
  1711. "ddl": ddl_content,
  1712. "table_name": table_name,
  1713. "data_source": data_source,
  1714. }
  1715. except Exception as e:
  1716. logger.error(f"生成BusinessDomain DDL失败,ID={bd_id}: {str(e)}")
  1717. return None
  1718. @staticmethod
  1719. def _handle_script_relationships(
  1720. data: Dict[str, Any],
  1721. dataflow_name: str,
  1722. name_en: str,
  1723. ):
  1724. """
  1725. 处理脚本关系,在Neo4j图数据库中创建从source BusinessDomain到DataFlow的
  1726. INPUT关系,以及从DataFlow到target BusinessDomain的OUTPUT关系。
  1727. 关系模型:
  1728. - (source:BusinessDomain)-[:INPUT]->(dataflow:DataFlow)
  1729. - (dataflow:DataFlow)-[:OUTPUT]->(target:BusinessDomain)
  1730. Args:
  1731. data: 包含脚本信息的数据字典,应包含script_name, script_type,
  1732. schedule_status, source_table, target_table, update_mode
  1733. """
  1734. try:
  1735. # 从data中读取键值对
  1736. source_table_full = data.get("source_table", "")
  1737. target_table_full = data.get("target_table", "")
  1738. # 处理source_table和target_table的格式
  1739. # 格式: "label:name" 或 直接 "name"
  1740. source_table = (
  1741. source_table_full.split(":")[-1]
  1742. if ":" in source_table_full
  1743. else source_table_full
  1744. )
  1745. target_table = (
  1746. target_table_full.split(":")[-1]
  1747. if ":" in target_table_full
  1748. else target_table_full
  1749. )
  1750. source_label = (
  1751. source_table_full.split(":")[0]
  1752. if ":" in source_table_full
  1753. else "BusinessDomain"
  1754. )
  1755. target_label = (
  1756. target_table_full.split(":")[0]
  1757. if ":" in target_table_full
  1758. else "BusinessDomain"
  1759. )
  1760. # 验证必要字段
  1761. if not source_table or not target_table:
  1762. logger.warning(
  1763. "source_table或target_table为空,跳过关系创建: "
  1764. "source_table=%s, target_table=%s",
  1765. source_table,
  1766. target_table,
  1767. )
  1768. return
  1769. logger.info(
  1770. "开始创建INPUT/OUTPUT关系: %s -[INPUT]-> %s -[OUTPUT]-> %s",
  1771. source_table,
  1772. dataflow_name,
  1773. target_table,
  1774. )
  1775. with connect_graph().session() as session:
  1776. # 步骤1:获取DataFlow节点ID
  1777. dataflow_query = """
  1778. MATCH (df:DataFlow {name_zh: $dataflow_name})
  1779. RETURN id(df) as dataflow_id
  1780. """
  1781. df_result = session.run(
  1782. dataflow_query, # type: ignore[arg-type]
  1783. {"dataflow_name": dataflow_name},
  1784. ).single()
  1785. if not df_result:
  1786. logger.error(f"未找到DataFlow节点: {dataflow_name}")
  1787. return
  1788. dataflow_id = df_result["dataflow_id"]
  1789. # 步骤2:获取或创建source节点
  1790. # 优先通过name_en匹配,其次通过name匹配
  1791. source_query = f"""
  1792. MATCH (source:{source_label})
  1793. WHERE source.name_en = $source_table OR source.name = $source_table
  1794. RETURN id(source) as source_id
  1795. LIMIT 1
  1796. """
  1797. source_result = session.run(
  1798. source_query, # type: ignore[arg-type]
  1799. {"source_table": source_table},
  1800. ).single()
  1801. if not source_result:
  1802. logger.warning(
  1803. "未找到source节点: %s,将创建新节点",
  1804. source_table,
  1805. )
  1806. # 创建source节点
  1807. create_source_query = f"""
  1808. CREATE (source:{source_label} {{
  1809. name: $source_table,
  1810. name_en: $source_table,
  1811. created_at: $created_at,
  1812. type: 'source'
  1813. }})
  1814. RETURN id(source) as source_id
  1815. """
  1816. source_result = session.run(
  1817. create_source_query, # type: ignore[arg-type]
  1818. {
  1819. "source_table": source_table,
  1820. "created_at": get_formatted_time(),
  1821. },
  1822. ).single()
  1823. source_id = source_result["source_id"] if source_result else None
  1824. # 步骤3:获取或创建target节点
  1825. target_query = f"""
  1826. MATCH (target:{target_label})
  1827. WHERE target.name_en = $target_table OR target.name = $target_table
  1828. RETURN id(target) as target_id
  1829. LIMIT 1
  1830. """
  1831. target_result = session.run(
  1832. target_query, # type: ignore[arg-type]
  1833. {"target_table": target_table},
  1834. ).single()
  1835. if not target_result:
  1836. logger.warning(
  1837. "未找到target节点: %s,将创建新节点",
  1838. target_table,
  1839. )
  1840. # 创建target节点
  1841. create_target_query = f"""
  1842. CREATE (target:{target_label} {{
  1843. name: $target_table,
  1844. name_en: $target_table,
  1845. created_at: $created_at,
  1846. type: 'target'
  1847. }})
  1848. RETURN id(target) as target_id
  1849. """
  1850. target_result = session.run(
  1851. create_target_query, # type: ignore[arg-type]
  1852. {
  1853. "target_table": target_table,
  1854. "created_at": get_formatted_time(),
  1855. },
  1856. ).single()
  1857. target_id = target_result["target_id"] if target_result else None
  1858. if not source_id or not target_id:
  1859. logger.error(
  1860. "无法获取source或target节点ID: source_id=%s, target_id=%s",
  1861. source_id,
  1862. target_id,
  1863. )
  1864. return
  1865. # 步骤4:创建 INPUT 关系 (source)-[:INPUT]->(dataflow)
  1866. create_input_query = """
  1867. MATCH (source), (dataflow:DataFlow)
  1868. WHERE id(source) = $source_id AND id(dataflow) = $dataflow_id
  1869. MERGE (source)-[r:INPUT]->(dataflow)
  1870. ON CREATE SET r.created_at = $created_at
  1871. ON MATCH SET r.updated_at = $created_at
  1872. RETURN r
  1873. """
  1874. input_result = session.run(
  1875. create_input_query, # type: ignore[arg-type]
  1876. {
  1877. "source_id": source_id,
  1878. "dataflow_id": dataflow_id,
  1879. "created_at": get_formatted_time(),
  1880. },
  1881. ).single()
  1882. if input_result:
  1883. logger.info(
  1884. "成功创建INPUT关系: %s -> %s",
  1885. source_table,
  1886. dataflow_name,
  1887. )
  1888. else:
  1889. logger.warning(
  1890. "INPUT关系创建失败或已存在: %s -> %s",
  1891. source_table,
  1892. dataflow_name,
  1893. )
  1894. # 步骤5:创建 OUTPUT 关系 (dataflow)-[:OUTPUT]->(target)
  1895. create_output_query = """
  1896. MATCH (dataflow:DataFlow), (target)
  1897. WHERE id(dataflow) = $dataflow_id AND id(target) = $target_id
  1898. MERGE (dataflow)-[r:OUTPUT]->(target)
  1899. ON CREATE SET r.created_at = $created_at
  1900. ON MATCH SET r.updated_at = $created_at
  1901. RETURN r
  1902. """
  1903. output_result = session.run(
  1904. create_output_query, # type: ignore[arg-type]
  1905. {
  1906. "dataflow_id": dataflow_id,
  1907. "target_id": target_id,
  1908. "created_at": get_formatted_time(),
  1909. },
  1910. ).single()
  1911. if output_result:
  1912. logger.info(
  1913. "成功创建OUTPUT关系: %s -> %s",
  1914. dataflow_name,
  1915. target_table,
  1916. )
  1917. else:
  1918. logger.warning(
  1919. "OUTPUT关系创建失败或已存在: %s -> %s",
  1920. dataflow_name,
  1921. target_table,
  1922. )
  1923. logger.info(
  1924. "血缘关系创建完成: %s -[INPUT]-> %s -[OUTPUT]-> %s",
  1925. source_table,
  1926. dataflow_name,
  1927. target_table,
  1928. )
  1929. except Exception as e:
  1930. logger.error(f"处理脚本关系失败: {str(e)}")
  1931. raise e
  1932. @staticmethod
  1933. def get_business_domain_list() -> List[Dict[str, Any]]:
  1934. """
  1935. 获取BusinessDomain节点列表
  1936. Returns:
  1937. BusinessDomain节点列表,每个节点包含 id, name_zh, name_en, tag
  1938. """
  1939. try:
  1940. logger.info("开始查询BusinessDomain节点列表")
  1941. with connect_graph().session() as session:
  1942. # 查询所有BusinessDomain节点及其LABEL关系指向的标签(支持多标签)
  1943. query = """
  1944. MATCH (bd:BusinessDomain)
  1945. OPTIONAL MATCH (bd)-[:LABEL]->(label:DataLabel)
  1946. RETURN id(bd) as id,
  1947. bd.name_zh as name_zh,
  1948. bd.name_en as name_en,
  1949. bd.create_time as create_time,
  1950. collect({
  1951. id: id(label),
  1952. name_zh: label.name_zh,
  1953. name_en: label.name_en
  1954. }) as tags
  1955. ORDER BY create_time DESC
  1956. """
  1957. result = session.run(query)
  1958. bd_list = []
  1959. for record in result:
  1960. # 处理标签数组,过滤掉空标签
  1961. tags = record.get("tags", [])
  1962. tag_list = [tag for tag in tags if tag.get("id") is not None]
  1963. bd_item = {
  1964. "id": record["id"],
  1965. "name_zh": record.get("name_zh", "") or "",
  1966. "name_en": record.get("name_en", "") or "",
  1967. "tag": tag_list,
  1968. }
  1969. bd_list.append(bd_item)
  1970. logger.info(f"成功查询到 {len(bd_list)} 个BusinessDomain节点")
  1971. return bd_list
  1972. except Exception as e:
  1973. logger.error(f"查询BusinessDomain节点列表失败: {str(e)}")
  1974. raise e
  1975. @staticmethod
  1976. def _register_data_product(
  1977. data: Dict[str, Any],
  1978. dataflow_name: str,
  1979. name_en: str,
  1980. dataflow_id: Optional[int] = None,
  1981. ) -> None:
  1982. """
  1983. 注册数据产品到数据服务
  1984. 当数据流创建成功后,自动将其注册为数据产品,
  1985. 以便在数据服务模块中展示和管理。
  1986. 从 script_requirement.target_table 中获取 BusinessDomain ID,
  1987. 然后查询 Neo4j 获取对应节点的 name_zh 和 name_en 作为数据产品名称。
  1988. Args:
  1989. data: 数据流配置数据
  1990. dataflow_name: 数据流名称(中文)
  1991. name_en: 数据流英文名
  1992. dataflow_id: 数据流ID(Neo4j节点ID)
  1993. """
  1994. try:
  1995. # 从script_requirement中获取target_table(BusinessDomain ID列表)
  1996. script_requirement = data.get("script_requirement")
  1997. description = data.get("describe", "")
  1998. # 解析 script_requirement
  1999. req_json: Optional[Dict[str, Any]] = None
  2000. if script_requirement:
  2001. if isinstance(script_requirement, dict):
  2002. req_json = script_requirement
  2003. elif isinstance(script_requirement, str):
  2004. try:
  2005. parsed = json.loads(script_requirement)
  2006. if isinstance(parsed, dict):
  2007. req_json = parsed
  2008. except (json.JSONDecodeError, TypeError):
  2009. pass
  2010. # 获取target_table中的BusinessDomain ID列表
  2011. target_bd_ids: List[int] = []
  2012. if req_json:
  2013. target_table_ids = req_json.get("target_table", [])
  2014. if isinstance(target_table_ids, list):
  2015. target_bd_ids = [
  2016. int(bid) for bid in target_table_ids if bid is not None
  2017. ]
  2018. elif target_table_ids is not None:
  2019. target_bd_ids = [int(target_table_ids)]
  2020. # 如果有rule字段,添加到描述中
  2021. rule = req_json.get("rule", "")
  2022. if rule and not description:
  2023. description = rule
  2024. # 如果没有target_table ID,则不注册数据产品
  2025. if not target_bd_ids:
  2026. logger.warning(
  2027. f"数据流 {dataflow_name} 没有指定target_table,跳过数据产品注册"
  2028. )
  2029. return
  2030. # 从Neo4j查询每个BusinessDomain节点的name_zh和name_en,以及关联数据源的schema
  2031. with connect_graph().session() as session:
  2032. for bd_id in target_bd_ids:
  2033. try:
  2034. # 查询BusinessDomain节点信息及其关联的数据源schema
  2035. query = """
  2036. MATCH (bd:BusinessDomain)
  2037. WHERE id(bd) = $bd_id
  2038. OPTIONAL MATCH (bd)-[:COME_FROM]->(ds:DataSource)
  2039. RETURN bd.name_zh as name_zh,
  2040. bd.name_en as name_en,
  2041. bd.describe as describe,
  2042. ds.schema as ds_schema
  2043. """
  2044. result = session.run(query, bd_id=bd_id).single()
  2045. if not result:
  2046. logger.warning(
  2047. f"未找到ID为 {bd_id} 的BusinessDomain节点,跳过"
  2048. )
  2049. continue
  2050. # 使用BusinessDomain节点的name_zh和name_en
  2051. product_name = result.get("name_zh") or ""
  2052. product_name_en = result.get("name_en") or ""
  2053. # 如果没有name_zh,使用name_en
  2054. if not product_name:
  2055. product_name = product_name_en
  2056. # 如果没有name_en,使用name_zh转换
  2057. if not product_name_en:
  2058. product_name_en = product_name.lower().replace(" ", "_")
  2059. # 目标表名使用BusinessDomain的name_en
  2060. target_table = product_name_en
  2061. # 如果BusinessDomain有describe且当前description为空,使用它
  2062. bd_describe = result.get("describe") or ""
  2063. if bd_describe and not description:
  2064. description = bd_describe
  2065. # 从关联的数据源获取schema,如果没有则默认为public
  2066. target_schema = result.get("ds_schema") or "public"
  2067. # 调用数据产品服务进行注册
  2068. DataProductService.register_data_product(
  2069. product_name=product_name,
  2070. product_name_en=product_name_en,
  2071. target_table=target_table,
  2072. target_schema=target_schema,
  2073. description=description,
  2074. source_dataflow_id=dataflow_id,
  2075. source_dataflow_name=dataflow_name,
  2076. created_by=data.get("created_by", "dataflow"),
  2077. )
  2078. logger.info(
  2079. f"数据产品注册成功: {product_name} -> "
  2080. f"{target_schema}.{target_table}"
  2081. )
  2082. except Exception as bd_error:
  2083. logger.error(
  2084. f"处理BusinessDomain {bd_id} 失败: {str(bd_error)}"
  2085. )
  2086. # 继续处理下一个
  2087. except Exception as e:
  2088. logger.error(f"注册数据产品失败: {str(e)}")
  2089. raise