pipeline-service/pipeline_service/dingtalk_approval.py
ymq 1d743df4e3 feat(approval): 钉钉审批接成产线审批关卡
复用引擎既有 approval_gate 机制,不重造状态机:
- 新增 dingtalk_approval.py:注册 step_type='dingtalk_approval'(interactive),
  进入步骤自动向钉钉发起审批;注册 biz_type='pipeline_approval' 回调钩子,
  审批结果回来调 approval_approve/reject 推进产线(内部已 save_artifact+resume_task)
- executor._handle_interactive_step 加外部通知钩子(按 step_type meta.notifier 分派),
  并把 tenant_id 从 _execute_step 传下来(原 step_info 里无此字段,会永远取空)
- biz_id 编码 tenant_id:task_id:step_name,回调据此定位步骤
- 软依赖 dingdingflow:未加载时步骤仍挂起等人工审批,不推钉钉、不报错
- 通知失败只记日志不改步骤状态,避免外部系统故障导致步骤 FAILED
2026-08-25 18:22:03 +08:00

150 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""钉钉审批适配器 — 把 dingdingflow 模块接成产线的审批关卡。
设计:不重造审批状态机。产线引擎已内置 approval_gate 交互步骤
(executor 遇到即置 WAITING 挂起,approval_approve/reject 会
save_artifact + STATE_COMPLETED + resume_task 自动推进)。
本适配器只做两件事:
1. 注册 step_type='dingtalk_approval'(interactive),进入该步骤时
自动向钉钉发起审批实例;
2. 注册 dingdingflow 的 biz_type 回调钩子,钉钉审批结果回来时
调 approval_approve / approval_reject 推进产线。
biz_id 编码为 "{tenant_id}:{task_id}:{step_name}",回调据此定位步骤。
宿主集成(在 load_pipeline_service() 之后调用):
from pipeline_service.dingtalk_approval import load_dingtalk_approval
load_dingtalk_approval()
未安装 dingdingflow 时本模块自动降级:step_type 仍注册(步骤照常挂起
等人工在产线页面审批),只是不向钉钉推送。
"""
import json
import logging
from ahserver.serverenv import ServerEnv
from .step_registry import register_step_type
from .human import approval_approve, approval_reject
logger = logging.getLogger("pipeline.dingtalk_approval")
STEP_TYPE = "dingtalk_approval"
BIZ_TYPE = "pipeline_approval"
BIZ_ID_SEP = ":"
def _encode_biz_id(tenant_id, task_id, step_name):
"""审批记录里存的业务 ID,回调靠它定位产线步骤。"""
return BIZ_ID_SEP.join([str(tenant_id), str(task_id), str(step_name)])
def _decode_biz_id(biz_id):
"""反解 biz_id。step_name 可能含分隔符,故只切前两段。"""
parts = str(biz_id).split(BIZ_ID_SEP, 2)
if len(parts) != 3:
raise ValueError("bad biz_id: %s" % biz_id)
return parts[0], parts[1], parts[2]
async def notify_dingtalk_approval(task_id, step_name, step_type, version,
input_data, step_config, tenant_id=""):
"""进入审批步骤时向钉钉发起审批实例。
由 executor 的交互步骤钩子调用(见 _INTERACTIVE_NOTIFIERS)。
失败不抛异常:钉钉推送失败不应让产线步骤变 FAILED,
步骤仍停在 WAITING,可在产线页面人工审批兜底。
"""
env = ServerEnv()
submit_approval = getattr(env, "submit_approval", None)
if submit_approval is None:
logger.warning("dingdingflow 未加载,步骤 %s 仅挂起等人工审批(未推钉钉)", step_name)
return None
if not isinstance(step_config, dict):
step_config = {}
title = step_config.get("approval_title") or "产线审批:%s" % step_name
applicant_id = (step_config.get("applicant_id")
or (input_data or {}).get("applicant_id", "")
or "")
biz_type = step_config.get("biz_type") or BIZ_TYPE
biz_id = _encode_biz_id(tenant_id, task_id, step_name)
try:
result = await submit_approval(biz_type, biz_id, title, applicant_id,
str(tenant_id) or "0")
approval_id = (result or {}).get("approval_id", "")
logger.info("钉钉审批已发起: step=%s approval_id=%s biz_id=%s",
step_name, approval_id, biz_id)
return approval_id
except Exception as e:
logger.error("钉钉审批发起失败(步骤仍可人工审批): step=%s err=%s", step_name, e)
return None
async def on_dingtalk_approval_done(biz_id, status, approval_id, comment):
"""钉钉审批结果回调 → 推进产线步骤。
dingdingflow 的 register_biz_handler 钩子签名。
status ∈ ('approved', 'rejected', 'cancelled')
"""
try:
tenant_id, task_id, step_name = _decode_biz_id(biz_id)
except ValueError as e:
logger.error("回调 biz_id 解析失败: %s", e)
return
reviewer_id = "dingtalk:%s" % (approval_id or "")
if status == "approved":
raw = await approval_approve(tenant_id, task_id, step_name,
reviewer_id, comment)
elif status in ("rejected", "cancelled"):
# cancelled 也走 reject:产线不能停在 WAITING 永久卡死
raw = await approval_reject(tenant_id, task_id, step_name,
reviewer_id, comment)
else:
logger.warning("未知审批状态,忽略: %s", status)
return
try:
parsed = json.loads(raw) if isinstance(raw, str) else (raw or {})
except (ValueError, TypeError):
parsed = {}
if parsed.get("success"):
logger.info("产线步骤已推进: task=%s step=%s status=%s",
task_id, step_name, status)
else:
logger.error("产线步骤推进失败: task=%s step=%s status=%s msg=%s",
task_id, step_name, status, parsed.get("message"))
def load_dingtalk_approval():
"""注册钉钉审批步骤类型 + 回调钩子。"""
env = ServerEnv()
# 1. 注册为交互步骤:executor 遇到即置 WAITING 挂起,不需要 handler
register_step_type(STEP_TYPE, {
"display_name": "钉钉审批",
"category": "interactive",
"is_interactive": True,
"description": "向钉钉发起审批,审批通过后产线继续,驳回则中止",
"notifier": "notify_dingtalk_approval",
})
# 2. 暴露通知函数(executor 交互步骤钩子 / 手工重推都能用)
env.notify_dingtalk_approval = notify_dingtalk_approval
# 3. 注册审批结果回调钩子到 dingdingflow
register_biz_handler = getattr(env, "register_biz_handler", None)
if register_biz_handler:
register_biz_handler(BIZ_TYPE, on_dingtalk_approval_done)
logger.info("dingtalk_approval loaded — biz_type=%s 钩子已注册", BIZ_TYPE)
else:
logger.warning("dingdingflow 未加载:step_type=%s 已注册但审批结果无法自动回流,"
"请先 load_dingdingflow()", STEP_TYPE)
return True