| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389 |
- """工作流引擎 —— 读取节点与连线执行审批流转"""
- from __future__ import annotations
- import json
- import logging
- import re
- from datetime import datetime
- from typing import Any, Dict, List, Optional
- from sqlalchemy import text
- from sqlalchemy.orm import Session
- from app.crud import workflow as wf_crud
- from app.models.models import (
- SysUser,
- SysUserRole,
- WfNode,
- WfProcessInstance,
- WfTask,
- WfTransition,
- )
- from app.services.workflow_callback import trigger_callback
- logger = logging.getLogger("sqxp.workflow_engine")
- _FORBIDDEN_SQL_KEYWORDS = re.compile(
- r"\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE)\b", re.IGNORECASE
- )
- class WorkflowEngine:
- def __init__(self, db: Session):
- self.db = db
- # ------------------------------------------------------------------
- # start_process
- # ------------------------------------------------------------------
- def start_process(
- self,
- *,
- process_def_id: str,
- business_type: Optional[str] = None,
- business_id: Optional[str] = None,
- starter_user_id: Optional[str] = None,
- variables: Optional[Dict[str, Any]] = None,
- callback_url: Optional[str] = None,
- tenant_no: Optional[str] = None,
- user_id: Optional[str] = None,
- ) -> WfProcessInstance:
- version = wf_crud.get_latest_version(self.db, process_def_id)
- if not version:
- raise ValueError("流程定义没有可用版本,请先发布")
- instance = wf_crud.create_instance(
- self.db,
- process_version_id=version.id,
- business_type=business_type,
- business_id=business_id,
- starter_user_id=starter_user_id,
- variables=variables,
- callback_url=callback_url,
- tenant_no=tenant_no,
- user_id=user_id,
- )
- nodes = wf_crud.get_nodes_by_version(self.db, version.id)
- start_node = next((n for n in nodes if n.node_type == "start"), None)
- if not start_node:
- raise ValueError("流程缺少开始节点")
- self._advance(instance.id, start_node.node_key, tenant_no or "shanxia2.0")
- self.db.refresh(instance)
- return instance
- # ------------------------------------------------------------------
- # approve_task
- # ------------------------------------------------------------------
- def approve_task(self, task_id: str, comment: str, tenant_no: str) -> WfProcessInstance:
- task = wf_crud.get_task(self.db, task_id)
- if not task:
- raise ValueError("任务不存在")
- if task.status != "pending":
- raise ValueError(f"任务状态为 {task.status},无法审批")
- task.status = "approved"
- task.comment_text = comment
- task.update_time = datetime.now()
- self.db.add(task)
- self.db.commit()
- instance = wf_crud.get_instance(self.db, task.instance_id)
- if not instance:
- raise ValueError("流程实例不存在")
- version_id = instance.process_version_id
- nodes = wf_crud.get_nodes_by_version(self.db, version_id)
- current_node = next(
- (n for n in nodes if n.node_key == task.node_key), None
- )
- config = self._parse_node_config(current_node)
- pass_type = config.get("passType", "any")
- if pass_type == "any":
- others = wf_crud.get_pending_tasks_by_node(
- self.db, task.instance_id, task.node_key
- )
- for t in others:
- t.status = "withdrawn"
- t.update_time = datetime.now()
- self.db.add(t)
- self.db.commit()
- self._advance(instance.id, task.node_key, tenant_no)
- else:
- remaining = wf_crud.get_pending_tasks_by_node(
- self.db, task.instance_id, task.node_key
- )
- if not remaining:
- self._advance(instance.id, task.node_key, tenant_no)
- self.db.refresh(instance)
- return instance
- # ------------------------------------------------------------------
- # reject_task
- # ------------------------------------------------------------------
- def reject_task(self, task_id: str, comment: str, tenant_no: str) -> WfProcessInstance:
- task = wf_crud.get_task(self.db, task_id)
- if not task:
- raise ValueError("任务不存在")
- if task.status != "pending":
- raise ValueError(f"任务状态为 {task.status},无法驳回")
- task.status = "rejected"
- task.comment_text = comment
- task.update_time = datetime.now()
- self.db.add(task)
- self.db.commit()
- others = wf_crud.get_pending_tasks_by_node(
- self.db, task.instance_id, task.node_key
- )
- for t in others:
- t.status = "withdrawn"
- t.update_time = datetime.now()
- self.db.add(t)
- self.db.commit()
- instance = wf_crud.get_instance(self.db, task.instance_id)
- if instance:
- wf_crud.update_instance_status(self.db, instance.id, "rejected")
- self.db.refresh(instance)
- trigger_callback(self.db, instance, "rejected")
- return instance
- # ------------------------------------------------------------------
- # _advance (internal)
- # ------------------------------------------------------------------
- def _advance(
- self, instance_id: str, from_node_key: str, tenant_no: str
- ) -> None:
- instance = wf_crud.get_instance(self.db, instance_id)
- if not instance:
- return
- if instance.status != "running":
- return
- version_id = instance.process_version_id
- nodes = wf_crud.get_nodes_by_version(self.db, version_id)
- transitions = wf_crud.get_transitions_by_version(self.db, version_id)
- variables = self._parse_variables(instance)
- current_node = next(
- (n for n in nodes if n.node_key == from_node_key), None
- )
- if not current_node:
- return
- out_edges = [
- t for t in transitions if t.from_node_key == from_node_key
- ]
- if not out_edges:
- return
- if current_node.node_type in ("start", "approval"):
- target_key = out_edges[0].to_node_key
- elif current_node.node_type == "condition":
- target_key = self._evaluate_condition(out_edges, variables)
- else:
- target_key = out_edges[0].to_node_key
- next_node = next((n for n in nodes if n.node_key == target_key), None)
- if next_node:
- self._execute_node(instance_id, next_node, variables, tenant_no)
- # ------------------------------------------------------------------
- # _execute_node
- # ------------------------------------------------------------------
- def _execute_node(
- self,
- instance_id: str,
- node: WfNode,
- variables: Dict[str, Any],
- tenant_no: str,
- ) -> None:
- if node.node_type == "end":
- wf_crud.update_instance_status(self.db, instance_id, "completed")
- instance = wf_crud.get_instance(self.db, instance_id)
- if instance:
- trigger_callback(self.db, instance, "completed")
- return
- if node.node_type == "approval":
- config = self._parse_node_config(node)
- user_ids = self._resolve_approvers(config, variables, tenant_no)
- for uid in user_ids:
- wf_crud.create_task(
- self.db,
- instance_id=instance_id,
- node_key=node.node_key,
- assignee_user_id=uid,
- tenant_no=tenant_no,
- )
- return
- if node.node_type == "condition":
- self._advance(instance_id, node.node_key, tenant_no)
- return
- self._advance(instance_id, node.node_key, tenant_no)
- # ------------------------------------------------------------------
- # _resolve_approvers
- # ------------------------------------------------------------------
- def _resolve_approvers(
- self,
- config: Dict[str, Any],
- variables: Dict[str, Any],
- tenant_no: str,
- ) -> List[str]:
- strategy = config.get("strategy", "users")
- if strategy == "users":
- return config.get("userIds", [])
- if strategy == "roles":
- role_ids = config.get("roleIds", [])
- if not role_ids:
- return []
- rows = (
- self.db.query(SysUser.id)
- .join(SysUserRole, SysUserRole.user_id == SysUser.id)
- .filter(
- SysUserRole.role_id.in_(role_ids),
- SysUser.del_flag == 0,
- SysUser.status == "1",
- )
- .all()
- )
- return [r.id for r in rows]
- if strategy == "sql":
- sql_template = config.get("sql", "")
- if not sql_template or not sql_template.strip().upper().startswith("SELECT"):
- raise ValueError("SQL 审批人策略必须以 SELECT 开头")
- if _FORBIDDEN_SQL_KEYWORDS.search(sql_template):
- raise ValueError("SQL 审批人策略包含禁止的关键字")
- try:
- result = self.db.execute(text(sql_template), variables or {})
- return [str(row[0]) for row in result.fetchall()]
- except Exception as exc:
- logger.exception("SQL 审批人解析失败: %s", exc)
- raise ValueError(f"SQL 审批人解析失败: {exc}") from exc
- return []
- # ------------------------------------------------------------------
- # _evaluate_condition
- # ------------------------------------------------------------------
- def _evaluate_condition(
- self, out_edges: List[WfTransition], variables: Dict[str, Any]
- ) -> str:
- unconditional_fallback = out_edges[-1].to_node_key if out_edges else ""
- for edge in out_edges:
- cond_str = edge.condition_expr
- if not cond_str:
- continue
- try:
- rules = json.loads(cond_str)
- except (json.JSONDecodeError, TypeError):
- continue
- if self._check_rules(rules, variables):
- return edge.to_node_key
- return unconditional_fallback
- # ------------------------------------------------------------------
- # _check_rules (recursive)
- # ------------------------------------------------------------------
- def _check_rules(self, rules: Any, variables: Dict[str, Any]) -> bool:
- if isinstance(rules, list):
- return any(self._check_rules(r, variables) for r in rules)
- if isinstance(rules, dict):
- if "and" in rules:
- return all(self._check_rules(r, variables) for r in rules["and"])
- if "or" in rules:
- return any(self._check_rules(r, variables) for r in rules["or"])
- if "rules" in rules:
- return self._check_rules(rules["rules"], variables)
- if "field" in rules:
- actual = self._get_variable(rules["field"], variables)
- op = rules.get("op", "==")
- expected = rules.get("value")
- return self._compare(actual, op, expected)
- return False
- # ------------------------------------------------------------------
- # _compare
- # ------------------------------------------------------------------
- @staticmethod
- def _compare(actual: Any, op: str, expected: Any) -> bool:
- try:
- if op == "==":
- return str(actual) == str(expected)
- if op == "!=":
- return str(actual) != str(expected)
- if op == ">":
- return float(actual) > float(expected)
- if op == "<":
- return float(actual) < float(expected)
- if op == ">=":
- return float(actual) >= float(expected)
- if op == "<=":
- return float(actual) <= float(expected)
- if op == "in":
- return str(actual) in ([str(e) for e in expected] if isinstance(expected, list) else str(expected))
- if op == "not_in":
- return str(actual) not in ([str(e) for e in expected] if isinstance(expected, list) else str(expected))
- if op == "contains":
- return str(expected) in str(actual)
- except (ValueError, TypeError):
- return False
- return False
- # ------------------------------------------------------------------
- # _get_variable (dot-notation)
- # ------------------------------------------------------------------
- @staticmethod
- def _get_variable(field: str, variables: Dict[str, Any]) -> Any:
- parts = field.split(".")
- current: Any = variables
- for part in parts:
- if isinstance(current, dict):
- current = current.get(part)
- else:
- return None
- return current
- # ------------------------------------------------------------------
- # helpers
- # ------------------------------------------------------------------
- @staticmethod
- def _parse_node_config(node: Optional[WfNode]) -> Dict[str, Any]:
- if not node or not node.config_json:
- return {}
- try:
- return json.loads(node.config_json)
- except (json.JSONDecodeError, TypeError):
- return {}
- @staticmethod
- def _parse_variables(instance: WfProcessInstance) -> Dict[str, Any]:
- if not instance.variables_json:
- return {}
- try:
- return json.loads(instance.variables_json)
- except (json.JSONDecodeError, TypeError):
- return {}
|