workflow_engine.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  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. WfNode,
  14. WfProcessInstance,
  15. WfTask,
  16. WfTransition,
  17. )
  18. from app.services.web_auth_roles import list_user_ids_with_any_effective_role_ids
  19. from app.services.workflow_callback import trigger_callback, validate_callback_url
  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. safe_callback = validate_callback_url(callback_url)
  46. instance = wf_crud.create_instance(
  47. self.db,
  48. process_version_id=version.id,
  49. business_type=business_type,
  50. business_id=business_id,
  51. starter_user_id=starter_user_id,
  52. variables=variables,
  53. callback_url=safe_callback,
  54. tenant_no=tenant_no,
  55. user_id=user_id,
  56. )
  57. nodes = wf_crud.get_nodes_by_version(self.db, version.id)
  58. start_node = next((n for n in nodes if n.node_type == "start"), None)
  59. if not start_node:
  60. raise ValueError("流程缺少开始节点")
  61. self._advance(instance.id, start_node.node_key, tenant_no or "zkxw")
  62. self.db.refresh(instance)
  63. return instance
  64. # ------------------------------------------------------------------
  65. # approve_task
  66. # ------------------------------------------------------------------
  67. def approve_task(
  68. self,
  69. task_id: str,
  70. comment: str,
  71. tenant_no: str,
  72. *,
  73. actor_user_id: Optional[str] = None,
  74. ) -> WfProcessInstance:
  75. task = wf_crud.get_task(self.db, task_id)
  76. if not task:
  77. raise ValueError("任务不存在")
  78. if task.status != "pending":
  79. raise ValueError(f"任务状态为 {task.status},无法审批")
  80. self._assert_task_action_allowed(task, tenant_no, actor_user_id)
  81. task.status = "approved"
  82. task.comment_text = comment
  83. task.update_time = datetime.now()
  84. self.db.add(task)
  85. self.db.commit()
  86. instance = wf_crud.get_instance(self.db, task.instance_id)
  87. if not instance:
  88. raise ValueError("流程实例不存在")
  89. version_id = instance.process_version_id
  90. nodes = wf_crud.get_nodes_by_version(self.db, version_id)
  91. current_node = next(
  92. (n for n in nodes if n.node_key == task.node_key), None
  93. )
  94. config = self._parse_node_config(current_node)
  95. pass_type = config.get("passType", "any")
  96. if pass_type == "any":
  97. others = wf_crud.get_pending_tasks_by_node(
  98. self.db, task.instance_id, task.node_key
  99. )
  100. for t in others:
  101. t.status = "withdrawn"
  102. t.update_time = datetime.now()
  103. self.db.add(t)
  104. self.db.commit()
  105. self._advance(instance.id, task.node_key, tenant_no)
  106. else:
  107. remaining = wf_crud.get_pending_tasks_by_node(
  108. self.db, task.instance_id, task.node_key
  109. )
  110. if not remaining:
  111. self._advance(instance.id, task.node_key, tenant_no)
  112. self.db.refresh(instance)
  113. return instance
  114. # ------------------------------------------------------------------
  115. # reject_task
  116. # ------------------------------------------------------------------
  117. def reject_task(
  118. self,
  119. task_id: str,
  120. comment: str,
  121. tenant_no: str,
  122. *,
  123. actor_user_id: Optional[str] = None,
  124. ) -> WfProcessInstance:
  125. task = wf_crud.get_task(self.db, task_id)
  126. if not task:
  127. raise ValueError("任务不存在")
  128. if task.status != "pending":
  129. raise ValueError(f"任务状态为 {task.status},无法驳回")
  130. self._assert_task_action_allowed(task, tenant_no, actor_user_id)
  131. task.status = "rejected"
  132. task.comment_text = comment
  133. task.update_time = datetime.now()
  134. self.db.add(task)
  135. self.db.commit()
  136. others = wf_crud.get_pending_tasks_by_node(
  137. self.db, task.instance_id, task.node_key
  138. )
  139. for t in others:
  140. t.status = "withdrawn"
  141. t.update_time = datetime.now()
  142. self.db.add(t)
  143. self.db.commit()
  144. instance = wf_crud.get_instance(self.db, task.instance_id)
  145. if instance:
  146. wf_crud.update_instance_status(self.db, instance.id, "rejected")
  147. self.db.refresh(instance)
  148. trigger_callback(self.db, instance, "rejected")
  149. return instance
  150. def _assert_task_action_allowed(
  151. self,
  152. task: WfTask,
  153. tenant_no: str,
  154. actor_user_id: Optional[str],
  155. ) -> None:
  156. """仅办理人本人可审批/驳回;并校验任务所属实例租户。"""
  157. instance = wf_crud.get_instance(self.db, task.instance_id)
  158. if not instance:
  159. raise ValueError("流程实例不存在")
  160. inst_tenant = (instance.tenant_no or "").strip()
  161. if inst_tenant and tenant_no and inst_tenant != tenant_no:
  162. raise ValueError("无权操作其他租户的审批任务")
  163. if not actor_user_id:
  164. raise ValueError("无法识别当前用户,禁止审批")
  165. assignee = (task.assignee_user_id or "").strip()
  166. if not assignee or assignee != actor_user_id:
  167. raise ValueError("仅任务办理人可审批或驳回")
  168. # ------------------------------------------------------------------
  169. # _advance (internal)
  170. # ------------------------------------------------------------------
  171. def _advance(
  172. self, instance_id: str, from_node_key: str, tenant_no: str
  173. ) -> None:
  174. instance = wf_crud.get_instance(self.db, instance_id)
  175. if not instance:
  176. return
  177. if instance.status != "running":
  178. return
  179. version_id = instance.process_version_id
  180. nodes = wf_crud.get_nodes_by_version(self.db, version_id)
  181. transitions = wf_crud.get_transitions_by_version(self.db, version_id)
  182. variables = self._parse_variables(instance)
  183. current_node = next(
  184. (n for n in nodes if n.node_key == from_node_key), None
  185. )
  186. if not current_node:
  187. return
  188. out_edges = [
  189. t for t in transitions if t.from_node_key == from_node_key
  190. ]
  191. if not out_edges:
  192. return
  193. if current_node.node_type in ("start", "approval"):
  194. target_key = out_edges[0].to_node_key
  195. elif current_node.node_type == "condition":
  196. target_key = self._evaluate_condition(out_edges, variables)
  197. else:
  198. target_key = out_edges[0].to_node_key
  199. next_node = next((n for n in nodes if n.node_key == target_key), None)
  200. if next_node:
  201. self._execute_node(instance_id, next_node, variables, tenant_no)
  202. # ------------------------------------------------------------------
  203. # _execute_node
  204. # ------------------------------------------------------------------
  205. def _execute_node(
  206. self,
  207. instance_id: str,
  208. node: WfNode,
  209. variables: Dict[str, Any],
  210. tenant_no: str,
  211. ) -> None:
  212. if node.node_type == "end":
  213. wf_crud.update_instance_status(self.db, instance_id, "completed")
  214. instance = wf_crud.get_instance(self.db, instance_id)
  215. if instance:
  216. trigger_callback(self.db, instance, "completed")
  217. return
  218. if node.node_type == "approval":
  219. config = self._parse_node_config(node)
  220. user_ids = self._resolve_approvers(config, variables, tenant_no)
  221. for uid in user_ids:
  222. wf_crud.create_task(
  223. self.db,
  224. instance_id=instance_id,
  225. node_key=node.node_key,
  226. assignee_user_id=uid,
  227. tenant_no=tenant_no,
  228. )
  229. return
  230. if node.node_type == "condition":
  231. self._advance(instance_id, node.node_key, tenant_no)
  232. return
  233. self._advance(instance_id, node.node_key, tenant_no)
  234. # ------------------------------------------------------------------
  235. # _resolve_approvers
  236. # ------------------------------------------------------------------
  237. def _resolve_approvers(
  238. self,
  239. config: Dict[str, Any],
  240. variables: Dict[str, Any],
  241. tenant_no: str,
  242. ) -> List[str]:
  243. strategy = config.get("strategy", "users")
  244. if strategy == "users":
  245. return config.get("userIds", [])
  246. if strategy == "roles":
  247. role_ids = config.get("roleIds", [])
  248. if not role_ids:
  249. return []
  250. user_ids = list_user_ids_with_any_effective_role_ids(
  251. self.db, tenant_no=tenant_no, role_ids=role_ids
  252. )
  253. if not user_ids:
  254. return []
  255. rows = (
  256. self.db.query(SysUser.id)
  257. .filter(
  258. SysUser.id.in_(user_ids),
  259. SysUser.tenant_no == tenant_no,
  260. SysUser.del_flag == 0,
  261. SysUser.status == "1",
  262. )
  263. .all()
  264. )
  265. return [r.id for r in rows]
  266. if strategy == "sql":
  267. sql_template = config.get("sql", "")
  268. if not sql_template or not sql_template.strip().upper().startswith("SELECT"):
  269. raise ValueError("SQL 审批人策略必须以 SELECT 开头")
  270. if _FORBIDDEN_SQL_KEYWORDS.search(sql_template):
  271. raise ValueError("SQL 审批人策略包含禁止的关键字")
  272. try:
  273. result = self.db.execute(text(sql_template), variables or {})
  274. return [str(row[0]) for row in result.fetchall()]
  275. except Exception as exc:
  276. logger.exception("SQL 审批人解析失败: %s", exc)
  277. raise ValueError(f"SQL 审批人解析失败: {exc}") from exc
  278. return []
  279. # ------------------------------------------------------------------
  280. # _evaluate_condition
  281. # ------------------------------------------------------------------
  282. def _evaluate_condition(
  283. self, out_edges: List[WfTransition], variables: Dict[str, Any]
  284. ) -> str:
  285. unconditional_fallback = out_edges[-1].to_node_key if out_edges else ""
  286. for edge in out_edges:
  287. cond_str = edge.condition_expr
  288. if not cond_str:
  289. continue
  290. try:
  291. rules = json.loads(cond_str)
  292. except (json.JSONDecodeError, TypeError):
  293. continue
  294. if self._check_rules(rules, variables):
  295. return edge.to_node_key
  296. return unconditional_fallback
  297. # ------------------------------------------------------------------
  298. # _check_rules (recursive)
  299. # ------------------------------------------------------------------
  300. def _check_rules(self, rules: Any, variables: Dict[str, Any]) -> bool:
  301. if isinstance(rules, list):
  302. return any(self._check_rules(r, variables) for r in rules)
  303. if isinstance(rules, dict):
  304. if "and" in rules:
  305. return all(self._check_rules(r, variables) for r in rules["and"])
  306. if "or" in rules:
  307. return any(self._check_rules(r, variables) for r in rules["or"])
  308. if "rules" in rules:
  309. return self._check_rules(rules["rules"], variables)
  310. if "field" in rules:
  311. actual = self._get_variable(rules["field"], variables)
  312. op = rules.get("op", "==")
  313. expected = rules.get("value")
  314. return self._compare(actual, op, expected)
  315. return False
  316. # ------------------------------------------------------------------
  317. # _compare
  318. # ------------------------------------------------------------------
  319. @staticmethod
  320. def _compare(actual: Any, op: str, expected: Any) -> bool:
  321. try:
  322. if op == "==":
  323. return str(actual) == str(expected)
  324. if op == "!=":
  325. return str(actual) != str(expected)
  326. if op == ">":
  327. return float(actual) > float(expected)
  328. if op == "<":
  329. return float(actual) < float(expected)
  330. if op == ">=":
  331. return float(actual) >= float(expected)
  332. if op == "<=":
  333. return float(actual) <= float(expected)
  334. if op == "in":
  335. return str(actual) in ([str(e) for e in expected] if isinstance(expected, list) else str(expected))
  336. if op == "not_in":
  337. return str(actual) not in ([str(e) for e in expected] if isinstance(expected, list) else str(expected))
  338. if op == "contains":
  339. return str(expected) in str(actual)
  340. except (ValueError, TypeError):
  341. return False
  342. return False
  343. # ------------------------------------------------------------------
  344. # _get_variable (dot-notation)
  345. # ------------------------------------------------------------------
  346. @staticmethod
  347. def _get_variable(field: str, variables: Dict[str, Any]) -> Any:
  348. parts = field.split(".")
  349. current: Any = variables
  350. for part in parts:
  351. if isinstance(current, dict):
  352. current = current.get(part)
  353. else:
  354. return None
  355. return current
  356. # ------------------------------------------------------------------
  357. # helpers
  358. # ------------------------------------------------------------------
  359. @staticmethod
  360. def _parse_node_config(node: Optional[WfNode]) -> Dict[str, Any]:
  361. if not node or not node.config_json:
  362. return {}
  363. try:
  364. return json.loads(node.config_json)
  365. except (json.JSONDecodeError, TypeError):
  366. return {}
  367. @staticmethod
  368. def _parse_variables(instance: WfProcessInstance) -> Dict[str, Any]:
  369. if not instance.variables_json:
  370. return {}
  371. try:
  372. return json.loads(instance.variables_json)
  373. except (json.JSONDecodeError, TypeError):
  374. return {}