workflow_callback.py 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  1. """工作流回调服务 —— 流程状态变更时通知业务系统"""
  2. from __future__ import annotations
  3. import logging
  4. from typing import TYPE_CHECKING, Any, Callable, Dict, Optional
  5. if TYPE_CHECKING:
  6. from app.models.models import WfProcessInstance
  7. logger = logging.getLogger("sqxp.workflow_callback")
  8. # ---------------------------------------------------------------------------
  9. # 本地回调注册表:内部路径(以 / 开头)直接在进程内调用,不走 HTTP
  10. # ---------------------------------------------------------------------------
  11. _LocalCallbackFn = Callable[[Any, str, str], None] # (db, business_id, event) -> None
  12. _LOCAL_CALLBACKS: Dict[str, _LocalCallbackFn] = {}
  13. def register_local_callback(path: str, fn: _LocalCallbackFn) -> None:
  14. """注册本地回调函数。当 callback_url 以 path 开头时直接调用 fn。"""
  15. _LOCAL_CALLBACKS[path] = fn
  16. def _dispatch_local(db: Any, instance: WfProcessInstance, event: str) -> bool:
  17. """尝试本地直调。返回 True 表示已处理,False 表示无匹配的本地回调。"""
  18. url = instance.callback_url or ""
  19. if not url.startswith("/"):
  20. return False
  21. for prefix, fn in _LOCAL_CALLBACKS.items():
  22. if url.startswith(prefix):
  23. try:
  24. fn(db, instance.business_id or "", event)
  25. logger.info(
  26. "工作流本地回调成功: event=%s instance=%s path=%s",
  27. event,
  28. instance.id,
  29. url,
  30. )
  31. return True
  32. except Exception:
  33. logger.exception(
  34. "工作流本地回调失败: event=%s instance=%s path=%s",
  35. event,
  36. instance.id,
  37. url,
  38. )
  39. return True # 已匹配但执行失败,不再走 HTTP
  40. return False
  41. # ---------------------------------------------------------------------------
  42. # trigger_callback(对外入口)
  43. # ---------------------------------------------------------------------------
  44. def validate_callback_url(callback_url: Optional[str]) -> Optional[str]:
  45. """校验并规范化 callback_url。
  46. 仅允许空值或已注册的本地内部路径(以 / 开头)。
  47. 禁止 http(s) 等外链,避免 SSRF。
  48. """
  49. url = (callback_url or "").strip()
  50. if not url:
  51. return None
  52. lower = url.lower()
  53. if lower.startswith("http://") or lower.startswith("https://") or "://" in url:
  54. raise ValueError("callbackUrl 仅支持已注册的本地内部路径,禁止 HTTP(S) 外链")
  55. if not url.startswith("/"):
  56. raise ValueError("callbackUrl 必须为以 / 开头的本地内部路径")
  57. matched = any(url.startswith(prefix) for prefix in _LOCAL_CALLBACKS)
  58. if not matched:
  59. raise ValueError("callbackUrl 未注册为本地回调,禁止使用")
  60. return url
  61. def trigger_callback(db: Any, instance: WfProcessInstance, event: str) -> None:
  62. """流程实例状态变更后,向业务系统发送回调通知。
  63. 仅支持已注册的本地内部路径(进程内直调)。不再发起任意 HTTP 回调,避免 SSRF。
  64. """
  65. if not instance.callback_url:
  66. return
  67. url = (instance.callback_url or "").strip()
  68. lower = url.lower()
  69. if lower.startswith("http://") or lower.startswith("https://") or "://" in url:
  70. logger.warning(
  71. "拒绝工作流 HTTP 回调(SSRF 防护): event=%s instance=%s url=%s",
  72. event,
  73. instance.id,
  74. url,
  75. )
  76. return
  77. if _dispatch_local(db, instance, event):
  78. return
  79. logger.warning(
  80. "工作流回调未匹配本地注册表,已跳过: event=%s instance=%s url=%s",
  81. event,
  82. instance.id,
  83. url,
  84. )