workflow_engine.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  1. """工作流引擎 —— 读取节点与连线执行审批流转"""
  2. from __future__ import annotations
  3. import json
  4. import logging
  5. import re
  6. from datetime import datetime
  7. from typing import Any, Dict, List, Optional
  8. from sqlalchemy import text
  9. from sqlalchemy.orm import Session
  10. from app.crud import workflow as wf_crud
  11. from app.models.models import (
  12. SysUser,
  13. SysUserRole,
  14. WfNode,
  15. WfProcessInstance,
  16. WfTask,
  17. WfTransition,
  18. )
  19. from app.services.workflow_callback import trigger_callback
  20. logger = logging.getLogger("sqxp.workflow_engine")
  21. _FORBIDDEN_SQL_KEYWORDS = re.compile(
  22. r"\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE)\b", re.IGNORECASE
  23. )
  24. class WorkflowEngine:
  25. def __init__(self, db: Session):
  26. self.db = db
  27. # ------------------------------------------------------------------
  28. # start_process
  29. # ------------------------------------------------------------------
  30. def start_process(
  31. self,
  32. *,
  33. process_def_id: str,
  34. business_type: Optional[str] = None,
  35. business_id: Optional[str] = None,
  36. starter_user_id: Optional[str] = None,
  37. variables: Optional[Dict[str, Any]] = None,
  38. callback_url: Optional[str] = None,
  39. tenant_no: Optional[str] = None,
  40. user_id: Optional[str] = None,
  41. ) -> WfProcessInstance:
  42. version = wf_crud.get_latest_version(self.db, process_def_id)
  43. if not version:
  44. raise ValueError("流程定义没有可用版本,请先发布")
  45. instance = wf_crud.create_instance(
  46. self.db,
  47. process_version_id=version.id,
  48. business_type=business_type,
  49. business_id=business_id,
  50. starter_user_id=starter_user_id,
  51. variables=variables,
  52. callback_url=callback_url,
  53. tenant_no=tenant_no,
  54. user_id=user_id,
  55. )
  56. nodes = wf_crud.get_nodes_by_version(self.db, version.id)
  57. start_node = next((n for n in nodes if n.node_type == "start"), None)
  58. if not start_node:
  59. raise ValueError("流程缺少开始节点")
  60. self._advance(instance.id, start_node.node_key, tenant_no or "shanxia2.0")
  61. self.db.refresh(instance)
  62. return instance
  63. # ------------------------------------------------------------------
  64. # approve_task
  65. # ------------------------------------------------------------------
  66. def approve_task(self, task_id: str, comment: str, tenant_no: str) -> WfProcessInstance:
  67. task = wf_crud.get_task(self.db, task_id)
  68. if not task:
  69. raise ValueError("任务不存在")
  70. if task.status != "pending":
  71. raise ValueError(f"任务状态为 {task.status},无法审批")
  72. task.status = "approved"
  73. task.comment_text = comment
  74. task.update_time = datetime.now()
  75. self.db.add(task)
  76. self.db.commit()
  77. instance = wf_crud.get_instance(self.db, task.instance_id)
  78. if not instance:
  79. raise ValueError("流程实例不存在")
  80. version_id = instance.process_version_id
  81. nodes = wf_crud.get_nodes_by_version(self.db, version_id)
  82. current_node = next(
  83. (n for n in nodes if n.node_key == task.node_key), None
  84. )
  85. config = self._parse_node_config(current_node)
  86. pass_type = config.get("passType", "any")
  87. if pass_type == "any":
  88. others = wf_crud.get_pending_tasks_by_node(
  89. self.db, task.instance_id, task.node_key
  90. )
  91. for t in others:
  92. t.status = "withdrawn"
  93. t.update_time = datetime.now()
  94. self.db.add(t)
  95. self.db.commit()
  96. self._advance(instance.id, task.node_key, tenant_no)
  97. else:
  98. remaining = wf_crud.get_pending_tasks_by_node(
  99. self.db, task.instance_id, task.node_key
  100. )
  101. if not remaining:
  102. self._advance(instance.id, task.node_key, tenant_no)
  103. self.db.refresh(instance)
  104. return instance
  105. # ------------------------------------------------------------------
  106. # reject_task
  107. # ------------------------------------------------------------------
  108. def reject_task(self, task_id: str, comment: str, tenant_no: str) -> WfProcessInstance:
  109. task = wf_crud.get_task(self.db, task_id)
  110. if not task:
  111. raise ValueError("任务不存在")
  112. if task.status != "pending":
  113. raise ValueError(f"任务状态为 {task.status},无法驳回")
  114. task.status = "rejected"
  115. task.comment_text = comment
  116. task.update_time = datetime.now()
  117. self.db.add(task)
  118. self.db.commit()
  119. others = wf_crud.get_pending_tasks_by_node(
  120. self.db, task.instance_id, task.node_key
  121. )
  122. for t in others:
  123. t.status = "withdrawn"
  124. t.update_time = datetime.now()
  125. self.db.add(t)
  126. self.db.commit()
  127. instance = wf_crud.get_instance(self.db, task.instance_id)
  128. if instance:
  129. wf_crud.update_instance_status(self.db, instance.id, "rejected")
  130. self.db.refresh(instance)
  131. trigger_callback(self.db, instance, "rejected")
  132. return instance
  133. # ------------------------------------------------------------------
  134. # _advance (internal)
  135. # ------------------------------------------------------------------
  136. def _advance(
  137. self, instance_id: str, from_node_key: str, tenant_no: str
  138. ) -> None:
  139. instance = wf_crud.get_instance(self.db, instance_id)
  140. if not instance:
  141. return
  142. if instance.status != "running":
  143. return
  144. version_id = instance.process_version_id
  145. nodes = wf_crud.get_nodes_by_version(self.db, version_id)
  146. transitions = wf_crud.get_transitions_by_version(self.db, version_id)
  147. variables = self._parse_variables(instance)
  148. current_node = next(
  149. (n for n in nodes if n.node_key == from_node_key), None
  150. )
  151. if not current_node:
  152. return
  153. out_edges = [
  154. t for t in transitions if t.from_node_key == from_node_key
  155. ]
  156. if not out_edges:
  157. return
  158. if current_node.node_type in ("start", "approval"):
  159. target_key = out_edges[0].to_node_key
  160. elif current_node.node_type == "condition":
  161. target_key = self._evaluate_condition(out_edges, variables)
  162. else:
  163. target_key = out_edges[0].to_node_key
  164. next_node = next((n for n in nodes if n.node_key == target_key), None)
  165. if next_node:
  166. self._execute_node(instance_id, next_node, variables, tenant_no)
  167. # ------------------------------------------------------------------
  168. # _execute_node
  169. # ------------------------------------------------------------------
  170. def _execute_node(
  171. self,
  172. instance_id: str,
  173. node: WfNode,
  174. variables: Dict[str, Any],
  175. tenant_no: str,
  176. ) -> None:
  177. if node.node_type == "end":
  178. wf_crud.update_instance_status(self.db, instance_id, "completed")
  179. instance = wf_crud.get_instance(self.db, instance_id)
  180. if instance:
  181. trigger_callback(self.db, instance, "completed")
  182. return
  183. if node.node_type == "approval":
  184. config = self._parse_node_config(node)
  185. user_ids = self._resolve_approvers(config, variables, tenant_no)
  186. for uid in user_ids:
  187. wf_crud.create_task(
  188. self.db,
  189. instance_id=instance_id,
  190. node_key=node.node_key,
  191. assignee_user_id=uid,
  192. tenant_no=tenant_no,
  193. )
  194. return
  195. if node.node_type == "condition":
  196. self._advance(instance_id, node.node_key, tenant_no)
  197. return
  198. self._advance(instance_id, node.node_key, tenant_no)
  199. # ------------------------------------------------------------------
  200. # _resolve_approvers
  201. # ------------------------------------------------------------------
  202. def _resolve_approvers(
  203. self,
  204. config: Dict[str, Any],
  205. variables: Dict[str, Any],
  206. tenant_no: str,
  207. ) -> List[str]:
  208. strategy = config.get("strategy", "users")
  209. if strategy == "users":
  210. return config.get("userIds", [])
  211. if strategy == "roles":
  212. role_ids = config.get("roleIds", [])
  213. if not role_ids:
  214. return []
  215. rows = (
  216. self.db.query(SysUser.id)
  217. .join(SysUserRole, SysUserRole.user_id == SysUser.id)
  218. .filter(
  219. SysUserRole.role_id.in_(role_ids),
  220. SysUser.del_flag == 0,
  221. SysUser.status == "1",
  222. )
  223. .all()
  224. )
  225. return [r.id for r in rows]
  226. if strategy == "sql":
  227. sql_template = config.get("sql", "")
  228. if not sql_template or not sql_template.strip().upper().startswith("SELECT"):
  229. raise ValueError("SQL 审批人策略必须以 SELECT 开头")
  230. if _FORBIDDEN_SQL_KEYWORDS.search(sql_template):
  231. raise ValueError("SQL 审批人策略包含禁止的关键字")
  232. try:
  233. result = self.db.execute(text(sql_template), variables or {})
  234. return [str(row[0]) for row in result.fetchall()]
  235. except Exception as exc:
  236. logger.exception("SQL 审批人解析失败: %s", exc)
  237. raise ValueError(f"SQL 审批人解析失败: {exc}") from exc
  238. return []
  239. # ------------------------------------------------------------------
  240. # _evaluate_condition
  241. # ------------------------------------------------------------------
  242. def _evaluate_condition(
  243. self, out_edges: List[WfTransition], variables: Dict[str, Any]
  244. ) -> str:
  245. unconditional_fallback = out_edges[-1].to_node_key if out_edges else ""
  246. for edge in out_edges:
  247. cond_str = edge.condition_expr
  248. if not cond_str:
  249. continue
  250. try:
  251. rules = json.loads(cond_str)
  252. except (json.JSONDecodeError, TypeError):
  253. continue
  254. if self._check_rules(rules, variables):
  255. return edge.to_node_key
  256. return unconditional_fallback
  257. # ------------------------------------------------------------------
  258. # _check_rules (recursive)
  259. # ------------------------------------------------------------------
  260. def _check_rules(self, rules: Any, variables: Dict[str, Any]) -> bool:
  261. if isinstance(rules, list):
  262. return any(self._check_rules(r, variables) for r in rules)
  263. if isinstance(rules, dict):
  264. if "and" in rules:
  265. return all(self._check_rules(r, variables) for r in rules["and"])
  266. if "or" in rules:
  267. return any(self._check_rules(r, variables) for r in rules["or"])
  268. if "rules" in rules:
  269. return self._check_rules(rules["rules"], variables)
  270. if "field" in rules:
  271. actual = self._get_variable(rules["field"], variables)
  272. op = rules.get("op", "==")
  273. expected = rules.get("value")
  274. return self._compare(actual, op, expected)
  275. return False
  276. # ------------------------------------------------------------------
  277. # _compare
  278. # ------------------------------------------------------------------
  279. @staticmethod
  280. def _compare(actual: Any, op: str, expected: Any) -> bool:
  281. try:
  282. if op == "==":
  283. return str(actual) == str(expected)
  284. if op == "!=":
  285. return str(actual) != str(expected)
  286. if op == ">":
  287. return float(actual) > float(expected)
  288. if op == "<":
  289. return float(actual) < float(expected)
  290. if op == ">=":
  291. return float(actual) >= float(expected)
  292. if op == "<=":
  293. return float(actual) <= float(expected)
  294. if op == "in":
  295. return str(actual) in ([str(e) for e in expected] if isinstance(expected, list) else str(expected))
  296. if op == "not_in":
  297. return str(actual) not in ([str(e) for e in expected] if isinstance(expected, list) else str(expected))
  298. if op == "contains":
  299. return str(expected) in str(actual)
  300. except (ValueError, TypeError):
  301. return False
  302. return False
  303. # ------------------------------------------------------------------
  304. # _get_variable (dot-notation)
  305. # ------------------------------------------------------------------
  306. @staticmethod
  307. def _get_variable(field: str, variables: Dict[str, Any]) -> Any:
  308. parts = field.split(".")
  309. current: Any = variables
  310. for part in parts:
  311. if isinstance(current, dict):
  312. current = current.get(part)
  313. else:
  314. return None
  315. return current
  316. # ------------------------------------------------------------------
  317. # helpers
  318. # ------------------------------------------------------------------
  319. @staticmethod
  320. def _parse_node_config(node: Optional[WfNode]) -> Dict[str, Any]:
  321. if not node or not node.config_json:
  322. return {}
  323. try:
  324. return json.loads(node.config_json)
  325. except (json.JSONDecodeError, TypeError):
  326. return {}
  327. @staticmethod
  328. def _parse_variables(instance: WfProcessInstance) -> Dict[str, Any]:
  329. if not instance.variables_json:
  330. return {}
  331. try:
  332. return json.loads(instance.variables_json)
  333. except (json.JSONDecodeError, TypeError):
  334. return {}