WIP-CHECKPOINT[auth-refactor]: 固化工程师崩溃前部分成果 + 同树其他未提交WIP(仅源码,不含密钥/二进制)-- 待重激活工程师续作
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 服务包
|
||||
# =============================================================================
|
||||
# 说明:自动化引擎服务包初始化。导出核心类,并定义 P0 四场景的默认动作计划
|
||||
# (管理端未配置时使用,保证引擎可演示闭环)。
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 默认场景动作计划(管理端可覆盖)
|
||||
# --------------------------------------------------------------------------
|
||||
# 每个动作项字段:
|
||||
# action_type : 语义动作类型(对应 action_registry 适配器)
|
||||
# adapter : 执行适配器(huorong/lianruan/ehr/internal)
|
||||
# risk_level : read / low / high(决定分级执行)
|
||||
# title/description : 展示信息
|
||||
# params : 动作入参
|
||||
# confirm_channel : 高危动作的确认渠道(agent=坐席审批 / h5=员工确认)
|
||||
# --------------------------------------------------------------------------
|
||||
DEFAULT_SCENARIO_CONFIGS: Dict[str, Dict] = {
|
||||
"terminal_locate": {
|
||||
"name": "终端定位",
|
||||
"description": "根据员工身份定位其名下终端(联软映射)。",
|
||||
"enabled": True,
|
||||
"trigger_conditions": {"intents": ["terminal_locate"], "keywords": ["定位", "终端", "电脑在哪"]},
|
||||
"approval_strategy": {"read": "auto", "low": "auto", "high": "approval"},
|
||||
"actions": [
|
||||
{
|
||||
"action_type": "terminal_locate",
|
||||
"adapter": "lianruan",
|
||||
"risk_level": "read",
|
||||
"title": "定位员工终端",
|
||||
"description": "查询该员工名下终端列表",
|
||||
"params": {},
|
||||
"confirm_channel": "agent",
|
||||
}
|
||||
],
|
||||
},
|
||||
"virus_dispose": {
|
||||
"name": "病毒查杀处置",
|
||||
"description": "对感染终端发起扫描并在确认后隔离查杀(火绒)。",
|
||||
"enabled": True,
|
||||
"trigger_conditions": {"intents": ["virus_dispose"], "keywords": ["病毒", "杀毒", "勒索", "木马"]},
|
||||
"approval_strategy": {"read": "auto", "low": "auto", "high": "approval"},
|
||||
"actions": [
|
||||
{
|
||||
"action_type": "virus_scan",
|
||||
"adapter": "huorong",
|
||||
"risk_level": "low",
|
||||
"title": "终端病毒扫描",
|
||||
"description": "对目标终端发起快速扫描",
|
||||
"params": {},
|
||||
"confirm_channel": "agent",
|
||||
},
|
||||
{
|
||||
"action_type": "virus_quarantine",
|
||||
"adapter": "huorong",
|
||||
"risk_level": "high",
|
||||
"title": "隔离并查杀终端",
|
||||
"description": "隔离目标终端并查杀病毒(高危,需坐席审批)",
|
||||
"params": {},
|
||||
"confirm_channel": "agent",
|
||||
},
|
||||
],
|
||||
},
|
||||
"software_install": {
|
||||
"name": "软件自助安装",
|
||||
"description": "向员工推送软件安装指引与下载链接(内部)。",
|
||||
"enabled": True,
|
||||
"trigger_conditions": {"intents": ["software_install"], "keywords": ["安装", "软件", "下载"]},
|
||||
"approval_strategy": {"read": "auto", "low": "auto", "high": "approval"},
|
||||
"actions": [
|
||||
{
|
||||
"action_type": "software_install_guide",
|
||||
"adapter": "internal",
|
||||
"risk_level": "low",
|
||||
"title": "推送软件安装指引",
|
||||
"description": "向员工推送软件下载与安装指引",
|
||||
"params": {"software_name": "", "download_url": ""},
|
||||
"confirm_channel": "agent",
|
||||
}
|
||||
],
|
||||
},
|
||||
"password_reset": {
|
||||
"name": "密码重置",
|
||||
"description": "生成密码重置链接(零信任 aTrust),需员工 H5 二次确认。",
|
||||
"enabled": True,
|
||||
"trigger_conditions": {"intents": ["password_reset"], "keywords": ["密码", "重置", "忘密码"]},
|
||||
"approval_strategy": {"read": "auto", "low": "auto", "high": "approval"},
|
||||
"actions": [
|
||||
{
|
||||
"action_type": "password_reset_link",
|
||||
"adapter": "internal",
|
||||
"risk_level": "high",
|
||||
"title": "发送密码重置链接",
|
||||
"description": "生成密码重置链接,需员工在 H5 二次确认后推送",
|
||||
"params": {"reset_link": ""},
|
||||
"confirm_channel": "h5",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def scenario_action_types() -> List[str]:
|
||||
"""返回全部已注册动作类型(调试/文档用)。"""
|
||||
return list(DEFAULT_SCENARIO_CONFIGS.keys())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 导出核心类(便于路由层 `from app.services.automation import ...`)
|
||||
# --------------------------------------------------------------------------
|
||||
from app.services.automation.action_registry import ( # noqa: E402
|
||||
ActionContext,
|
||||
BaseActionHandler,
|
||||
get_handler,
|
||||
register_builtin_handlers,
|
||||
)
|
||||
from app.services.automation.approval import ApprovalService # noqa: E402
|
||||
from app.services.automation.exception_handler import ( # noqa: E402
|
||||
AutomationException,
|
||||
to_app_exception,
|
||||
)
|
||||
from app.services.automation.executor import ActionExecutor # noqa: E402
|
||||
from app.services.automation.intent_router import IntentRouter # noqa: E402
|
||||
from app.services.automation.mapping_resolver import MappingResolver # noqa: E402
|
||||
from app.services.automation.progress_publisher import ( # noqa: E402
|
||||
publish_action_required,
|
||||
publish_error,
|
||||
publish_progress,
|
||||
publish_resolved,
|
||||
publish_takeover,
|
||||
register_ws,
|
||||
unregister_ws,
|
||||
)
|
||||
from app.services.automation.rollback import RollbackService # noqa: E402
|
||||
from app.services.automation.session_manager import ( # noqa: E402
|
||||
AutoSessionService,
|
||||
run_session_in_background,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_SCENARIO_CONFIGS",
|
||||
"scenario_action_types",
|
||||
"ActionContext",
|
||||
"BaseActionHandler",
|
||||
"get_handler",
|
||||
"register_builtin_handlers",
|
||||
"ApprovalService",
|
||||
"AutomationException",
|
||||
"to_app_exception",
|
||||
"ActionExecutor",
|
||||
"IntentRouter",
|
||||
"MappingResolver",
|
||||
"publish_action_required",
|
||||
"publish_error",
|
||||
"publish_progress",
|
||||
"publish_resolved",
|
||||
"publish_takeover",
|
||||
"register_ws",
|
||||
"unregister_ws",
|
||||
"RollbackService",
|
||||
"AutoSessionService",
|
||||
"run_session_in_background",
|
||||
]
|
||||
@@ -0,0 +1,203 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 动作适配器注册表
|
||||
# =============================================================================
|
||||
# 说明:每个场景动作由对应适配器(handler)真正执行。适配器复用阶段1-4 已落
|
||||
# 地的集成客户端(Huorong / Lianruan / EHR),对 aTrust(零信任)等
|
||||
# 密钥未到的系统提供安全的占位实现(明确报错,不静默成功)。
|
||||
#
|
||||
# 本期 P0 四个场景适配器:
|
||||
# - terminal_locate → LianruanClient.query_dev_by_params(员工→终端映射,read)
|
||||
# - virus_scan / virus_quarantine → HuorongClient(扫描/隔离,high)
|
||||
# - software_install_guide → 内部(推送软件安装指引链接,low)
|
||||
# - password_reset_link → 内部(推送密码重置链接,high,需员工 H5 确认)
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
from app.constants import AutomationErrorCode
|
||||
from app.integrations.base import BaseClientError
|
||||
from app.services.automation.exception_handler import AutomationException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 审计回调签名
|
||||
AuditFn = Optional[Callable[..., Awaitable[None]]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionContext:
|
||||
"""动作执行上下文,传递给各适配器。"""
|
||||
|
||||
db: Any
|
||||
session: Any
|
||||
action: Any
|
||||
mapping: Dict[str, Any] = field(default_factory=dict)
|
||||
clients: Dict[str, Any] = field(default_factory=dict)
|
||||
employee_id: str = ""
|
||||
audit: AuditFn = None
|
||||
|
||||
|
||||
class BaseActionHandler:
|
||||
"""动作适配器基类。"""
|
||||
|
||||
# 语义动作类型,用于注册表索引
|
||||
action_type: str = ""
|
||||
|
||||
async def execute(self, ctx: ActionContext) -> Dict[str, Any]:
|
||||
"""执行动作,返回结果 dict(会被写入 AutoAction.result)。
|
||||
|
||||
Raises:
|
||||
AutomationException: 业务错误(映射失败/配置缺失等)
|
||||
BaseClientError: 外部系统调用失败(executor 捕获后转 EXTERNAL_CALL_FAILED)
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class TerminalLocateHandler(BaseActionHandler):
|
||||
"""终端定位:通过联软按员工账号查终端(read)。"""
|
||||
|
||||
action_type = "terminal_locate"
|
||||
|
||||
async def execute(self, ctx: ActionContext) -> Dict[str, Any]:
|
||||
client = ctx.clients.get("lianruan")
|
||||
if client is None:
|
||||
# 联软不可用 → 转 EHR 兜底(映射解析器已尝试,这里直接报错)
|
||||
raise AutomationException(
|
||||
AutomationErrorCode.MAPPING_FAILED,
|
||||
"联软映射客户端未配置,无法定位终端",
|
||||
)
|
||||
data = await client.query_dev_by_params(strusername=ctx.employee_id)
|
||||
items = data.get("items", []) if isinstance(data, dict) else []
|
||||
terminals = [
|
||||
{
|
||||
"strdevname": getattr(t, "strdevname", ""),
|
||||
"strdevip": getattr(t, "strdevip", ""),
|
||||
"strusername": getattr(t, "strusername", ""),
|
||||
"strdeptname": getattr(t, "strdeptname", ""),
|
||||
}
|
||||
for t in items
|
||||
]
|
||||
return {
|
||||
"source": "lianruan",
|
||||
"employee_id": ctx.employee_id,
|
||||
"terminals": terminals,
|
||||
"total": len(terminals),
|
||||
}
|
||||
|
||||
|
||||
class VirusScanHandler(BaseActionHandler):
|
||||
"""病毒扫描:对映射到的终端发起快速扫描(low)。"""
|
||||
|
||||
action_type = "virus_scan"
|
||||
|
||||
async def execute(self, ctx: ActionContext) -> Dict[str, Any]:
|
||||
client_ids = (ctx.mapping or {}).get("client_ids") or []
|
||||
if not client_ids:
|
||||
raise AutomationException(
|
||||
AutomationErrorCode.MAPPING_FAILED, "未解析到目标终端,无法扫描"
|
||||
)
|
||||
client = ctx.clients.get("huorong")
|
||||
if client is None:
|
||||
raise AutomationException(
|
||||
AutomationErrorCode.CONFIG_ERROR, "火绒客户端未配置,无法执行病毒扫描"
|
||||
)
|
||||
result = await client.create_scan_task(client_ids=client_ids, scan_type="quick_scan")
|
||||
return {"client_ids": client_ids, "task": result}
|
||||
|
||||
|
||||
class VirusQuarantineHandler(BaseActionHandler):
|
||||
"""病毒查杀/隔离:隔离目标终端(high,需审批)。"""
|
||||
|
||||
action_type = "virus_quarantine"
|
||||
|
||||
async def execute(self, ctx: ActionContext) -> Dict[str, Any]:
|
||||
client_ids = (ctx.mapping or {}).get("client_ids") or []
|
||||
if not client_ids:
|
||||
raise AutomationException(
|
||||
AutomationErrorCode.MAPPING_FAILED, "未解析到目标终端,无法隔离"
|
||||
)
|
||||
client = ctx.clients.get("huorong")
|
||||
if client is None:
|
||||
raise AutomationException(
|
||||
AutomationErrorCode.CONFIG_ERROR, "火绒客户端未配置,无法隔离终端"
|
||||
)
|
||||
result = await client.isolate_terminal(client_ids=client_ids)
|
||||
return {"client_ids": client_ids, "task": result, "isolated": True}
|
||||
|
||||
|
||||
class SoftwareInstallHandler(BaseActionHandler):
|
||||
"""软件自安装指引:构造安装指引与下载链接(low,内部)。"""
|
||||
|
||||
action_type = "software_install_guide"
|
||||
|
||||
async def execute(self, ctx: ActionContext) -> Dict[str, Any]:
|
||||
payload = ctx.action.payload or {}
|
||||
software_name = payload.get("software_name", "")
|
||||
download_url = payload.get("download_url", "")
|
||||
guide = (
|
||||
f"请按以下步骤自助安装「{software_name or '所需软件'}」:\n"
|
||||
"1. 打开下载链接并完成安装;\n"
|
||||
"2. 如提示需要管理员权限,请在企业微信中发起「软件安装申请」审批;\n"
|
||||
"3. 安装完成后重启应用即可使用。"
|
||||
)
|
||||
return {
|
||||
"software_name": software_name,
|
||||
"download_url": download_url,
|
||||
"guide": guide,
|
||||
}
|
||||
|
||||
|
||||
class PasswordResetHandler(BaseActionHandler):
|
||||
"""密码重置:构造密码重置链接(high,需员工 H5 确认,内部)。"""
|
||||
|
||||
action_type = "password_reset_link"
|
||||
|
||||
async def execute(self, ctx: ActionContext) -> Dict[str, Any]:
|
||||
payload = ctx.action.payload or {}
|
||||
reset_link = payload.get("reset_link", "")
|
||||
# aTrust(零信任)密钥未到,本期以「重置链接推送」形式落地:
|
||||
# 链接由管理端配置(AUTOMATION_PASSWORD_RESET_URL),缺省给出占位说明。
|
||||
if not reset_link:
|
||||
reset_link = "about:blank#password-reset-not-configured"
|
||||
return {
|
||||
"employee_id": ctx.employee_id,
|
||||
"reset_link": reset_link,
|
||||
"note": "密码重置链接已生成,需员工在 H5 二次确认后推送至本人。",
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 注册表
|
||||
# --------------------------------------------------------------------------
|
||||
HANDLER_REGISTRY: Dict[str, BaseActionHandler] = {}
|
||||
|
||||
|
||||
def _register(handler: BaseActionHandler) -> None:
|
||||
HANDLER_REGISTRY[handler.action_type] = handler
|
||||
|
||||
|
||||
def register_builtin_handlers() -> None:
|
||||
"""注册内置动作适配器(幂等)。"""
|
||||
for cls in (
|
||||
TerminalLocateHandler,
|
||||
VirusScanHandler,
|
||||
VirusQuarantineHandler,
|
||||
SoftwareInstallHandler,
|
||||
PasswordResetHandler,
|
||||
):
|
||||
_register(cls())
|
||||
|
||||
|
||||
def get_handler(action_type: str) -> Optional[BaseActionHandler]:
|
||||
"""按动作类型取适配器;未注册返回 None。"""
|
||||
if not HANDLER_REGISTRY:
|
||||
register_builtin_handlers()
|
||||
return HANDLER_REGISTRY.get(action_type)
|
||||
|
||||
|
||||
# 模块导入即注册
|
||||
register_builtin_handlers()
|
||||
@@ -0,0 +1,92 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 审批单服务
|
||||
# =============================================================================
|
||||
# 说明:管理高危动作 / 员工二次确认对应的审批单(ApprovalTicket)。
|
||||
# 1. ensure_ticket:动作首次需要审批时创建(幂等,避免重复提单)
|
||||
# 2. decide:坐席审批或员工 H5 确认后更新状态
|
||||
# 3. get_pending_for_action:查询动作当前待决审批单
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.automation import ApprovalTicket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ApprovalService:
|
||||
"""审批单服务。"""
|
||||
|
||||
def __init__(self, db: Any):
|
||||
self.db = db
|
||||
|
||||
async def ensure_ticket(
|
||||
self, action: Any, channel: str, reason: Optional[str] = None
|
||||
) -> ApprovalTicket:
|
||||
"""确保动作存在一张待决审批单(幂等)。"""
|
||||
stmt = select(ApprovalTicket).where(
|
||||
ApprovalTicket.action_id == action.id,
|
||||
ApprovalTicket.status == "pending",
|
||||
)
|
||||
existing = (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
ticket = ApprovalTicket(
|
||||
action_id=action.id,
|
||||
session_id=action.session_id,
|
||||
channel=channel,
|
||||
status="pending",
|
||||
reason=reason,
|
||||
)
|
||||
self.db.add(ticket)
|
||||
await self.db.flush()
|
||||
return ticket
|
||||
|
||||
async def get_pending_for_action(self, action_id: str) -> Optional[ApprovalTicket]:
|
||||
"""查询动作当前待决审批单。"""
|
||||
stmt = select(ApprovalTicket).where(
|
||||
ApprovalTicket.action_id == action_id,
|
||||
ApprovalTicket.status == "pending",
|
||||
)
|
||||
return (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def decide(
|
||||
self,
|
||||
ticket_id: str,
|
||||
decision: str,
|
||||
note: Optional[str],
|
||||
approver_id: Optional[str],
|
||||
) -> ApprovalTicket:
|
||||
"""更新审批单决策。
|
||||
|
||||
Args:
|
||||
decision: approve / reject
|
||||
note: 审批意见
|
||||
approver_id: 审批人(坐席或员工)
|
||||
|
||||
Returns:
|
||||
ApprovalTicket: 更新后的审批单
|
||||
|
||||
Raises:
|
||||
ValueError: 审批单不存在或已决
|
||||
"""
|
||||
stmt = select(ApprovalTicket).where(ApprovalTicket.id == ticket_id)
|
||||
ticket = (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
if ticket is None:
|
||||
raise ValueError(f"审批单不存在: {ticket_id}")
|
||||
if ticket.status != "pending":
|
||||
return ticket
|
||||
|
||||
ticket.status = "approved" if decision == "approve" else "rejected"
|
||||
ticket.decision_note = note
|
||||
ticket.approver_id = approver_id
|
||||
ticket.decided_at = datetime.now(timezone.utc)
|
||||
await self.db.flush()
|
||||
return ticket
|
||||
@@ -0,0 +1,40 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 异常定义
|
||||
# =============================================================================
|
||||
# 说明:定义自动化引擎内部异常 AutomationException(携带数值错误码),
|
||||
# 以及 to_app_exception 转换为项目统一的 AppException,
|
||||
# 使全局异常处理器能输出 {code, data, message} 标准格式。
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.constants import AutomationErrorCode, automation_error_message
|
||||
from app.utils.response import AppException
|
||||
|
||||
|
||||
class AutomationException(Exception):
|
||||
"""自动化引擎内部异常。
|
||||
|
||||
Attributes:
|
||||
code: 自动化错误码(AutomationErrorCode 取值)
|
||||
message: 错误消息(缺省取错误码默认文案)
|
||||
data: 附加数据
|
||||
"""
|
||||
|
||||
def __init__(self, code: int, message: str = "", data: Any = None):
|
||||
self.code = code
|
||||
self.message = message or automation_error_message(code)
|
||||
self.data = data
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
def to_app_exception(exc: AutomationException) -> AppException:
|
||||
"""将 AutomationException 转为项目统一的 AppException。"""
|
||||
return AppException(code=exc.code, message=exc.message, data=exc.data)
|
||||
|
||||
|
||||
def automation_error_code(code: int) -> int:
|
||||
"""校验并返回合法的错误码(占位,便于未来扩展白名单)。"""
|
||||
return code
|
||||
@@ -0,0 +1,285 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 动作执行引擎
|
||||
# =============================================================================
|
||||
# 说明:按场景动作计划顺序执行,落实「分级执行」策略:
|
||||
# - read / low 风险 + real_exec 模式 → 自动执行
|
||||
# - high 风险 / plan_only 模式 → 挂起并生成审批单(坐席审批 或 员工 H5 确认)
|
||||
# 执行中任一动作失败 → 触发回滚补偿 + 转人工接管。
|
||||
# 全部成功 → 处置成功(resolved),调度静默关单。
|
||||
#
|
||||
# 设计要点:
|
||||
# - 主循环 run() 在遇到首个「需审批」动作时挂起并返回,等待 resume() 续跑
|
||||
# - resume() 更新审批单后再次调用 run() 继续后续动作(支持多步审批)
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.constants import (
|
||||
AUTOMATION_AUTO_EXECUTABLE_RISKS,
|
||||
AUTOMATION_SESSION_TERMINAL_STATES,
|
||||
AutomationErrorCode,
|
||||
)
|
||||
from app.database import _get_session_factory
|
||||
from app.integrations.base import BaseClientError
|
||||
from app.integrations.factory import (
|
||||
build_dify_client,
|
||||
build_ehr_client,
|
||||
build_huorong_client,
|
||||
build_lianruan_client,
|
||||
)
|
||||
from app.models.automation import AutoAction, AutoSession
|
||||
from app.services.automation.action_registry import ActionContext, get_handler
|
||||
from app.services.automation.approval import ApprovalService
|
||||
from app.services.automation.exception_handler import AutomationException
|
||||
from app.services.automation.progress_publisher import (
|
||||
cancel_silent_close,
|
||||
publish_progress,
|
||||
publish_resolved,
|
||||
publish_takeover,
|
||||
schedule_silent_close,
|
||||
)
|
||||
from app.services.automation.rollback import RollbackService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 静默关单默认 TTL(秒):处置成功后 10 分钟内员工无异议自动关单
|
||||
SILENT_CLOSE_TTL = 600
|
||||
|
||||
|
||||
class ActionExecutor:
|
||||
"""动作执行引擎。"""
|
||||
|
||||
def __init__(self, db: Any, redis: Any = None, audit: Any = None):
|
||||
self.db = db
|
||||
self.redis = redis
|
||||
self.audit = audit
|
||||
self.approval = ApprovalService(db)
|
||||
self.rollback = RollbackService(db)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 数据加载
|
||||
# --------------------------------------------------------------------------
|
||||
async def _load(self, session_id: str):
|
||||
"""加载会话及其动作(按动作顺序排序)。"""
|
||||
stmt = select(AutoSession).where(AutoSession.id == session_id)
|
||||
session = (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
if session is None:
|
||||
return None, []
|
||||
act_stmt = select(AutoAction).where(AutoAction.session_id == session_id)
|
||||
actions = list((await self.db.execute(act_stmt)).scalars().all())
|
||||
actions.sort(key=lambda a: a.action_index)
|
||||
return session, actions
|
||||
|
||||
async def _build_clients(self) -> Dict[str, Any]:
|
||||
"""构建外部客户端(缺失时置 None,不阻断主流程)。"""
|
||||
clients: Dict[str, Any] = {}
|
||||
for name, builder in (
|
||||
("huorong", build_huorong_client),
|
||||
("lianruan", build_lianruan_client),
|
||||
("ehr", build_ehr_client),
|
||||
("dify", build_dify_client),
|
||||
):
|
||||
try:
|
||||
clients[name] = await builder(self.db, audit=self.audit) if name != "dify" else await builder(audit=self.audit)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"构建外部客户端失败 {name}: {e}")
|
||||
clients[name] = None
|
||||
return clients
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 主循环
|
||||
# --------------------------------------------------------------------------
|
||||
async def run(self, session_id: str) -> None:
|
||||
"""执行会话动作计划(遇到审批闸门挂起返回,等待 resume)。"""
|
||||
session, actions = await self._load(session_id)
|
||||
if session is None:
|
||||
logger.warning(f"executor.run 会话不存在: {session_id}")
|
||||
return
|
||||
if session.status in AUTOMATION_SESSION_TERMINAL_STATES:
|
||||
logger.info(f"会话已终态,跳过执行: {session_id} status={session.status}")
|
||||
return
|
||||
|
||||
mapping = (session.meta or {}).get("mapping") or {}
|
||||
clients = await self._build_clients()
|
||||
|
||||
for action in actions:
|
||||
if action.status in ("success", "failed", "rejected", "skipped"):
|
||||
continue
|
||||
|
||||
# 已批准 → 直接执行
|
||||
if action.status == "approved":
|
||||
await self._do_execute(session, action, mapping, clients)
|
||||
continue
|
||||
|
||||
# 待决(pending / await_approval)→ 判断是否需审批闸门
|
||||
needs_gate = (
|
||||
action.risk_level not in AUTOMATION_AUTO_EXECUTABLE_RISKS
|
||||
) or (session.mode == "plan_only")
|
||||
|
||||
if needs_gate:
|
||||
channel = (action.payload or {}).get("confirm_channel") or "agent"
|
||||
if session.mode == "plan_only":
|
||||
channel = "agent" # 方案预览阶段统一走坐席确认
|
||||
ticket = await self.approval.ensure_ticket(
|
||||
action, channel=channel, reason=action.description
|
||||
)
|
||||
action.status = "await_approval"
|
||||
session.status = "paused"
|
||||
session.current_action_id = action.id
|
||||
await self.db.flush()
|
||||
await publish_action_required(session.id, action, ticket)
|
||||
return # 暂停,等待审批/确认后续跑
|
||||
|
||||
# 可自动执行
|
||||
await self._do_execute(session, action, mapping, clients)
|
||||
|
||||
# 全部动作处理完毕
|
||||
await self._finish_success(session)
|
||||
|
||||
async def _do_execute(
|
||||
self, session: AutoSession, action: AutoAction, mapping: dict, clients: dict
|
||||
) -> None:
|
||||
"""执行单个动作,处理成功/失败分支。"""
|
||||
handler = get_handler(action.action_type)
|
||||
if handler is None:
|
||||
action.status = "failed"
|
||||
action.error = f"未注册的动作类型: {action.action_type}"
|
||||
await self.db.flush()
|
||||
await self._on_action_failed(
|
||||
session,
|
||||
action,
|
||||
AutomationException(AutomationErrorCode.CONFIG_ERROR, action.error),
|
||||
)
|
||||
return
|
||||
|
||||
ctx = ActionContext(
|
||||
db=self.db,
|
||||
session=session,
|
||||
action=action,
|
||||
mapping=mapping,
|
||||
clients=clients,
|
||||
employee_id=session.employee_id,
|
||||
audit=self.audit,
|
||||
)
|
||||
try:
|
||||
result = await handler.execute(ctx)
|
||||
action.status = "success"
|
||||
action.result = result
|
||||
await self.db.flush()
|
||||
await publish_progress(
|
||||
session.id, "action_done", f"动作完成:{action.title}", action.id
|
||||
)
|
||||
except BaseClientError as e:
|
||||
action.status = "failed"
|
||||
action.error = str(e)
|
||||
await self.db.flush()
|
||||
await self._on_action_failed(
|
||||
session,
|
||||
action,
|
||||
AutomationException(AutomationErrorCode.EXTERNAL_CALL_FAILED, str(e)),
|
||||
)
|
||||
except AutomationException as e:
|
||||
action.status = "failed"
|
||||
action.error = e.message
|
||||
await self.db.flush()
|
||||
await self._on_action_failed(session, action, e)
|
||||
except Exception as e: # noqa: BLE001
|
||||
action.status = "failed"
|
||||
action.error = str(e)
|
||||
await self.db.flush()
|
||||
await self._on_action_failed(
|
||||
session,
|
||||
action,
|
||||
AutomationException(AutomationErrorCode.EXTERNAL_CALL_FAILED, str(e)),
|
||||
)
|
||||
|
||||
async def _on_action_failed(
|
||||
self, session: AutoSession, action: AutoAction, exc: AutomationException
|
||||
) -> None:
|
||||
"""动作失败:回滚已执行动作 + 转人工接管。"""
|
||||
try:
|
||||
await self.rollback.compensate(session, action)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"回滚补偿异常 session={session.id}: {e}")
|
||||
|
||||
session.status = "handoff"
|
||||
session.closed_by = "system(auto)"
|
||||
await self.db.flush()
|
||||
await publish_error(session.id, exc.code, exc.message)
|
||||
await publish_takeover(session.id, f"动作失败转人工:{exc.message}")
|
||||
|
||||
async def _finish_success(self, session: AutoSession) -> None:
|
||||
"""全部动作成功:置 resolved + 调度静默关单。"""
|
||||
session.status = "resolved"
|
||||
session.resolved_at = datetime.now(timezone.utc)
|
||||
session.current_action_id = None
|
||||
await self.db.flush()
|
||||
await publish_resolved(session.id, "处置已完成,等待您确认")
|
||||
schedule_silent_close(session.id, SILENT_CLOSE_TTL, on_expire=self._auto_close)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 审批/确认后续跑
|
||||
# --------------------------------------------------------------------------
|
||||
async def resume(
|
||||
self,
|
||||
session_id: str,
|
||||
action_id: str,
|
||||
decision: str,
|
||||
note: Optional[str],
|
||||
approver_id: Optional[str],
|
||||
) -> None:
|
||||
"""审批/确认结果回来后,更新审批单并续跑计划。"""
|
||||
session, actions = await self._load(session_id)
|
||||
if session is None:
|
||||
return
|
||||
if session.status in AUTOMATION_SESSION_TERMINAL_STATES:
|
||||
return
|
||||
|
||||
ticket = await self.approval.get_pending_for_action(action_id)
|
||||
if ticket is not None:
|
||||
await self.approval.decide(ticket.id, decision, note, approver_id)
|
||||
|
||||
action = next((a for a in actions if a.id == action_id), None)
|
||||
if action is None:
|
||||
return
|
||||
|
||||
if decision == "approve":
|
||||
action.status = "approved"
|
||||
action.approved_by = approver_id
|
||||
action.approved_at = datetime.now(timezone.utc)
|
||||
await self.db.flush()
|
||||
# 续跑主循环(会继续执行已批准动作及后续动作)
|
||||
await self.run(session_id)
|
||||
else:
|
||||
action.status = "rejected"
|
||||
session.status = "handoff"
|
||||
session.closed_by = approver_id
|
||||
await self.db.flush()
|
||||
cancel_silent_close(session_id)
|
||||
await publish_takeover(
|
||||
session.id, f"审批驳回转人工:{note or action.title}"
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 静默关单回调(独立会话,避免长事务)
|
||||
# --------------------------------------------------------------------------
|
||||
async def _auto_close(self, session_id: str) -> None:
|
||||
"""静默关单:仅在会话仍为 resolved 时自动关单。"""
|
||||
factory = _get_session_factory()
|
||||
async with factory() as db:
|
||||
stmt = select(AutoSession).where(AutoSession.id == session_id)
|
||||
session = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if session is None:
|
||||
return
|
||||
if session.status != "resolved":
|
||||
return # 已被接管/关单/反馈
|
||||
session.status = "closed"
|
||||
session.closed_by = "system(auto)"
|
||||
await db.commit()
|
||||
await publish_progress(session_id, "auto_closed", "已静默关单")
|
||||
@@ -0,0 +1,48 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 意图识别
|
||||
# =============================================================================
|
||||
# 说明:调用 Dify 识别员工诉求命中哪个自动化场景;Dify 未配置时走关键词兜底,
|
||||
# 保证 P0 四个场景在无真实 Dify 环境下也能跑通闭环。
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from app.integrations.dify import DifyClient, get_dify_client
|
||||
from app.integrations.factory import build_dify_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IntentRouter:
|
||||
"""意图识别路由器。"""
|
||||
|
||||
def __init__(self, db: Any = None, audit: Any = None):
|
||||
self.db = db
|
||||
self.audit = audit
|
||||
|
||||
async def detect(self, description: str, employee_id: str = "") -> Dict[str, Any]:
|
||||
"""识别意图,返回 {scenario_key, confidence, raw, error}。
|
||||
|
||||
优先走 Dify;若 Dify 未配置或调用失败,使用关键词兜底。
|
||||
"""
|
||||
client: Optional[DifyClient] = None
|
||||
try:
|
||||
client = await build_dify_client(audit=self.audit)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"构建 Dify 客户端失败,转关键词兜底: {e}")
|
||||
|
||||
if client is None:
|
||||
fb = DifyClient._fallback_intent(description)
|
||||
fb["error"] = "dify_not_configured"
|
||||
return fb
|
||||
|
||||
try:
|
||||
return await client.detect_intent(description, employee_id)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"Dify 意图识别异常,转关键词兜底: {e}")
|
||||
fb = DifyClient._fallback_intent(description)
|
||||
fb["error"] = str(e)
|
||||
return fb
|
||||
@@ -0,0 +1,153 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 员工→终端映射
|
||||
# =============================================================================
|
||||
# 说明:把员工(企微 UserID)解析为终端信息,供 virus_dispose 等场景使用。
|
||||
# 主源:联软(支持 strusername 直接映射)
|
||||
# 兜底:北森 EHR(仅给资产/部门 hint,无法提供火绒 client_id)
|
||||
# 结果缓存在 auto_mapping_cache(TTL),降低外部系统压力。
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.constants import MAPPING_SOURCE_PRIORITY
|
||||
from app.integrations.factory import build_ehr_client, build_lianruan_client
|
||||
from app.models.automation import MappingCache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 映射缓存 TTL(秒):联软数据 10 分钟内复用以降低外部压力
|
||||
MAPPING_CACHE_TTL_SECONDS = 600
|
||||
|
||||
|
||||
class MappingResolver:
|
||||
"""员工→终端映射解析器。"""
|
||||
|
||||
def __init__(self, db: Any = None, audit: Any = None):
|
||||
self.db = db
|
||||
self.audit = audit
|
||||
|
||||
async def resolve(self, employee_id: str, scenario_key: str = "") -> Dict[str, Any]:
|
||||
"""解析员工→终端映射。
|
||||
|
||||
Returns:
|
||||
Dict: {
|
||||
"employee_id", "source"(lianruan/ehr/None),
|
||||
"terminals": [...], "client_ids": [...](仅联软可给)
|
||||
}
|
||||
"""
|
||||
# 1. 查缓存(联软结果优先复用)
|
||||
cached = await self._load_cache(employee_id)
|
||||
if cached is not None:
|
||||
logger.info(f"命中映射缓存 employee={employee_id}, source={cached.get('source')}")
|
||||
return cached
|
||||
|
||||
terminals: list = []
|
||||
source: Optional[str] = None
|
||||
client_ids: list = []
|
||||
|
||||
# 2. 主源:联软(按员工账号直接映射)
|
||||
lianruan = None
|
||||
try:
|
||||
lianruan = await build_lianruan_client(self.db, audit=self.audit)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"构建联软客户端失败: {e}")
|
||||
|
||||
if lianruan is not None:
|
||||
try:
|
||||
data = await lianruan.query_dev_by_params(strusername=employee_id)
|
||||
items = data.get("items", []) if isinstance(data, dict) else []
|
||||
if items:
|
||||
terminals = [
|
||||
{
|
||||
"strdevname": getattr(t, "strdevname", ""),
|
||||
"strdevip": getattr(t, "strdevip", ""),
|
||||
"strusername": getattr(t, "strusername", ""),
|
||||
"strdeptname": getattr(t, "strdeptname", ""),
|
||||
}
|
||||
for t in items
|
||||
]
|
||||
# 火绒隔离以「终端标识」为目标;联软返回的是 hostname/ip,
|
||||
# 真实环境需按 hostname 做跨系统资产对齐(见交付说明假设)。
|
||||
client_ids = [
|
||||
(t.get("strdevname") or t.get("strdevip"))
|
||||
for t in terminals
|
||||
if (t.get("strdevname") or t.get("strdevip"))
|
||||
]
|
||||
source = "lianruan"
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"联软映射失败 employee={employee_id}: {e}")
|
||||
|
||||
# 3. 兜底:北森 EHR(仅 hint,无火绒 client_id)
|
||||
if not source:
|
||||
ehr = None
|
||||
try:
|
||||
ehr = await build_ehr_client(audit=self.audit)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"构建 EHR 客户端失败: {e}")
|
||||
if ehr is not None:
|
||||
try:
|
||||
hint = await ehr.get_terminal_by_employee(employee_id)
|
||||
if hint:
|
||||
terminals = [hint]
|
||||
source = "ehr"
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"EHR 映射兜底失败 employee={employee_id}: {e}")
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
"employee_id": employee_id,
|
||||
"source": source,
|
||||
"terminals": terminals,
|
||||
"client_ids": client_ids,
|
||||
}
|
||||
|
||||
# 4. 写缓存(仅联软结果值得缓存,EHR hint 不长期缓存)
|
||||
if source == "lianruan":
|
||||
await self._save_cache(employee_id, result)
|
||||
|
||||
return result
|
||||
|
||||
async def _load_cache(self, employee_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""读取未过期的映射缓存。"""
|
||||
if self.db is None:
|
||||
return None
|
||||
try:
|
||||
stmt = select(MappingCache).where(MappingCache.employee_id == employee_id)
|
||||
row = (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
if row.expires_at is not None and row.expires_at < datetime.now(timezone.utc):
|
||||
return None
|
||||
return row.mapped_data
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug(f"读映射缓存失败: {e}")
|
||||
return None
|
||||
|
||||
async def _save_cache(self, employee_id: str, data: Dict[str, Any]) -> None:
|
||||
"""写入映射缓存。"""
|
||||
if self.db is None:
|
||||
return
|
||||
try:
|
||||
stmt = select(MappingCache).where(MappingCache.employee_id == employee_id)
|
||||
row = (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
now = datetime.now(timezone.utc)
|
||||
if row is None:
|
||||
row = MappingCache(
|
||||
employee_id=employee_id,
|
||||
source=data.get("source", "lianruan"),
|
||||
mapped_data=data,
|
||||
expires_at=now + timedelta(seconds=MAPPING_CACHE_TTL_SECONDS),
|
||||
)
|
||||
self.db.add(row)
|
||||
else:
|
||||
row.mapped_data = data
|
||||
row.source = data.get("source", row.source)
|
||||
row.expires_at = now + timedelta(seconds=MAPPING_CACHE_TTL_SECONDS)
|
||||
await self.db.flush()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"写映射缓存失败: {e}")
|
||||
@@ -0,0 +1,189 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 进度推送
|
||||
# =============================================================================
|
||||
# 说明:负责把自动化处置过程实时推送给前端:
|
||||
# 1. 专用 WS 通道 /ws/automation/{session_id}(坐席工作台 + 员工 H5 均可连)
|
||||
# 2. 兜底推送:同时向坐席(/ws/{agent_id})和员工(/ws/h5/{employee_id})推送,
|
||||
# 保证未连专用 WS 时也能收到事件。
|
||||
# 3. 静默关单调度:处置成功后 N 分钟员工无异议则自动关单。
|
||||
#
|
||||
# 事件名统一以 automation. 前缀(见 app.constants)。
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Dict, Optional, Set
|
||||
|
||||
from app.constants import (
|
||||
AUTOMATION_SILENT_CLOSE_TTL,
|
||||
AUTOMATION_WS_ACTION_REQUIRED,
|
||||
AUTOMATION_WS_ERROR,
|
||||
AUTOMATION_WS_PROGRESS,
|
||||
AUTOMATION_WS_RESOLVED,
|
||||
AUTOMATION_WS_TAKEOVER,
|
||||
)
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 会话级专用 WS 连接注册表:session_id -> {websocket, ...}
|
||||
_automation_ws: Dict[str, Set[Any]] = {}
|
||||
|
||||
# 会话参与方(用于兜底推送):session_id -> {"agent_id":..,"employee_id":..}
|
||||
_session_parties: Dict[str, Dict[str, Optional[str]]] = {}
|
||||
|
||||
# 静默关单任务:session_id -> asyncio.Task
|
||||
_silent_close_tasks: Dict[str, asyncio.Task] = {}
|
||||
|
||||
|
||||
def register_ws(session_id: str, websocket: Any) -> None:
|
||||
"""注册专用 WS 连接。"""
|
||||
_automation_ws.setdefault(session_id, set()).add(websocket)
|
||||
|
||||
|
||||
def unregister_ws(session_id: str, websocket: Any) -> None:
|
||||
"""注销专用 WS 连接。"""
|
||||
conns = _automation_ws.get(session_id)
|
||||
if conns:
|
||||
conns.discard(websocket)
|
||||
if not conns:
|
||||
_automation_ws.pop(session_id, None)
|
||||
|
||||
|
||||
def set_parties(
|
||||
session_id: str,
|
||||
agent_id: Optional[str] = None,
|
||||
employee_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""记录会话参与方,用于兜底推送。"""
|
||||
parties = _session_parties.setdefault(
|
||||
session_id, {"agent_id": None, "employee_id": None}
|
||||
)
|
||||
if agent_id is not None:
|
||||
parties["agent_id"] = agent_id
|
||||
if employee_id is not None:
|
||||
parties["employee_id"] = employee_id
|
||||
|
||||
|
||||
def _build_message(event_type: str, session_id: str, data: Any) -> Dict[str, Any]:
|
||||
"""构造统一 WS 消息信封。"""
|
||||
return {"type": event_type, "session_id": session_id, "data": data or {}}
|
||||
|
||||
|
||||
async def _send_to_automation_ws(session_id: str, message: Dict[str, Any]) -> None:
|
||||
"""向专用 WS 连接推送(并清理失效连接)。"""
|
||||
conns = list(_automation_ws.get(session_id, set()))
|
||||
for ws in conns:
|
||||
try:
|
||||
await ws.send_json(message)
|
||||
except Exception: # noqa: BLE001
|
||||
unregister_ws(session_id, ws)
|
||||
|
||||
|
||||
async def _publish(event_type: str, session_id: str, data: Any) -> None:
|
||||
"""统一推送:专用 WS + 坐席/员工兜底 WS。"""
|
||||
message = _build_message(event_type, session_id, data)
|
||||
await _send_to_automation_ws(session_id, message)
|
||||
|
||||
parties = _session_parties.get(session_id, {})
|
||||
# 兜底推送给坐席
|
||||
if parties.get("agent_id"):
|
||||
await ws_manager.send_to_agent(parties["agent_id"], message)
|
||||
# 兜底推送给员工
|
||||
if parties.get("employee_id"):
|
||||
await ws_manager.send_to_employee(parties["employee_id"], message)
|
||||
|
||||
|
||||
async def publish_progress(
|
||||
session_id: str, step: str, message: str, action_id: Optional[str] = None
|
||||
) -> None:
|
||||
"""推送进度事件。"""
|
||||
await _publish(
|
||||
AUTOMATION_WS_PROGRESS,
|
||||
session_id,
|
||||
{"step": step, "message": message, "action_id": action_id},
|
||||
)
|
||||
|
||||
|
||||
async def publish_action_required(
|
||||
session_id: str,
|
||||
action: Any,
|
||||
ticket: Any,
|
||||
) -> None:
|
||||
"""推送需要审批/确认事件(坐席审批或员工 H5 确认)。"""
|
||||
await _publish(
|
||||
AUTOMATION_WS_ACTION_REQUIRED,
|
||||
session_id,
|
||||
{
|
||||
"action": {
|
||||
"id": action.id,
|
||||
"action_type": action.action_type,
|
||||
"title": action.title,
|
||||
"description": action.description,
|
||||
"risk_level": action.risk_level,
|
||||
"payload": action.payload,
|
||||
},
|
||||
"ticket": {
|
||||
"id": ticket.id,
|
||||
"channel": ticket.channel,
|
||||
"status": ticket.status,
|
||||
"reason": ticket.reason,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def publish_resolved(session_id: str, summary: str) -> None:
|
||||
"""推送处置成功事件。"""
|
||||
await _publish(AUTOMATION_WS_RESOLVED, session_id, {"summary": summary})
|
||||
|
||||
|
||||
async def publish_takeover(session_id: str, reason: str) -> None:
|
||||
"""推送转人工事件。"""
|
||||
await _publish(AUTOMATION_WS_TAKEOVER, session_id, {"reason": reason})
|
||||
|
||||
|
||||
async def publish_error(session_id: str, code: int, message: str) -> None:
|
||||
"""推送异常事件。"""
|
||||
await _publish(AUTOMATION_WS_ERROR, session_id, {"code": code, "message": message})
|
||||
|
||||
|
||||
def schedule_silent_close(
|
||||
session_id: str, ttl: int = AUTOMATION_SILENT_CLOSE_TTL, on_expire=None
|
||||
) -> None:
|
||||
"""调度静默关单:ttl 秒后若会话仍为 resolved,则自动关单。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
ttl: 静默期秒数(默认 600 = 10 分钟)
|
||||
on_expire: 到期回调 coroutine(通常 = AutoSessionService.auto_close)
|
||||
"""
|
||||
# 取消已有的同名任务,避免重复调度
|
||||
existing = _silent_close_tasks.get(session_id)
|
||||
if existing is not None and not existing.done():
|
||||
existing.cancel()
|
||||
|
||||
if on_expire is None:
|
||||
return
|
||||
|
||||
async def _wait_and_close() -> None:
|
||||
try:
|
||||
await asyncio.sleep(ttl)
|
||||
await on_expire(session_id)
|
||||
except asyncio.CancelledError: # 被新的调度取消
|
||||
pass
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"静默关单回调异常 session={session_id}: {e}")
|
||||
finally:
|
||||
_silent_close_tasks.pop(session_id, None)
|
||||
|
||||
_silent_close_tasks[session_id] = asyncio.create_task(_wait_and_close())
|
||||
|
||||
|
||||
def cancel_silent_close(session_id: str) -> None:
|
||||
"""取消静默关单调度(如会话已被接管/关单)。"""
|
||||
task = _silent_close_tasks.pop(session_id, None)
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
@@ -0,0 +1,58 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 回滚补偿
|
||||
# =============================================================================
|
||||
# 说明:动作执行失败时,对「已执行的前置动作」做补偿(逆向操作)。
|
||||
# 本期支持:病毒隔离(virus_quarantine) → 解除隔离(unisolate)。
|
||||
# 其余动作(只读/推送类)无需补偿,仅记录日志。
|
||||
# 补偿失败不阻断主流程(仅告警),由转人工接管兜底。
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.integrations.base import BaseClientError
|
||||
from app.integrations.factory import build_huorong_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RollbackService:
|
||||
"""回滚补偿服务。"""
|
||||
|
||||
def __init__(self, db: Any):
|
||||
self.db = db
|
||||
|
||||
async def compensate(self, session: Any, failed_action: Any) -> None:
|
||||
"""对失败动作做补偿(如有可逆操作)。
|
||||
|
||||
Args:
|
||||
session: 自动化会话(含 meta.mapping)
|
||||
failed_action: 失败的动作
|
||||
"""
|
||||
action_type = failed_action.action_type
|
||||
if action_type != "virus_quarantine":
|
||||
# 只读/推送类动作无需补偿
|
||||
logger.info(f"动作 {action_type} 无需回滚补偿")
|
||||
return
|
||||
|
||||
try:
|
||||
client = await build_huorong_client(self.db)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"构建火绒客户端失败,跳过回滚: {e}")
|
||||
return
|
||||
if client is None:
|
||||
return
|
||||
|
||||
mapping = (session.meta or {}).get("mapping") or {}
|
||||
client_ids = (failed_action.payload or {}).get("client_ids") or mapping.get(
|
||||
"client_ids"
|
||||
) or []
|
||||
if not client_ids:
|
||||
return
|
||||
try:
|
||||
await client.unisolate_terminal(client_ids=client_ids)
|
||||
logger.info(f"已对终端 {client_ids} 执行解除隔离补偿")
|
||||
except BaseClientError as e:
|
||||
logger.warning(f"回滚补偿(解除隔离)失败: {e}")
|
||||
@@ -0,0 +1,486 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 会话编排服务
|
||||
# =============================================================================
|
||||
# 说明:自动化会话的编排中枢,串联「意图识别 → 场景校验 → 终端映射 →
|
||||
# 动作计划生成 → 执行引擎」。同时提供会话 CRUD、转人工、结果反馈、
|
||||
# 静默关单等接口。
|
||||
#
|
||||
# 编排在后台任务中运行(run_session_in_background),API 创建会话后立即返回,
|
||||
# 进度通过 WS 实时推送。
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import settings
|
||||
from app.constants import AutomationErrorCode
|
||||
from app.database import _get_session_factory
|
||||
from app.models.automation import (
|
||||
ApprovalTicket,
|
||||
AutoAction,
|
||||
AutoSession,
|
||||
ScenarioConfig,
|
||||
)
|
||||
from app.services.automation.approval import ApprovalService
|
||||
from app.services.automation.exception_handler import AutomationException
|
||||
from app.services.automation.executor import ActionExecutor
|
||||
from app.services.automation.intent_router import IntentRouter
|
||||
from app.services.automation.mapping_resolver import MappingResolver
|
||||
from app.services.automation.progress_publisher import (
|
||||
cancel_silent_close,
|
||||
publish_progress,
|
||||
publish_takeover,
|
||||
)
|
||||
from app.services.automation import DEFAULT_SCENARIO_CONFIGS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 需要终端映射的场景
|
||||
_SCENARIOS_NEED_TERMINAL = {"virus_dispose", "terminal_locate"}
|
||||
|
||||
|
||||
class AutoSessionService:
|
||||
"""自动化会话编排服务。"""
|
||||
|
||||
def __init__(self, db: Any, redis: Any = None, audit: Any = None):
|
||||
self.db = db
|
||||
self.redis = redis
|
||||
self.audit = audit
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 会话 CRUD
|
||||
# --------------------------------------------------------------------------
|
||||
async def create_session(
|
||||
self,
|
||||
conversation_id: Optional[str],
|
||||
employee_id: str,
|
||||
description: str,
|
||||
mode: str = "real_exec",
|
||||
) -> AutoSession:
|
||||
"""创建自动化会话(状态=created)。"""
|
||||
session = AutoSession(
|
||||
conversation_id=conversation_id,
|
||||
employee_id=employee_id,
|
||||
mode=mode,
|
||||
title=(description or "")[:200],
|
||||
status="created",
|
||||
meta={"description": description},
|
||||
)
|
||||
self.db.add(session)
|
||||
await self.db.flush()
|
||||
return session
|
||||
|
||||
async def get_session(self, session_id: str) -> Optional[AutoSession]:
|
||||
"""按 ID 取会话。"""
|
||||
stmt = select(AutoSession).where(AutoSession.id == session_id)
|
||||
return (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def list_sessions(
|
||||
self,
|
||||
employee_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> List[AutoSession]:
|
||||
"""列出会话(支持过滤分页)。"""
|
||||
stmt = select(AutoSession)
|
||||
if employee_id:
|
||||
stmt = stmt.where(AutoSession.employee_id == employee_id)
|
||||
if agent_id:
|
||||
stmt = stmt.where(AutoSession.agent_id == agent_id)
|
||||
if status:
|
||||
stmt = stmt.where(AutoSession.status == status)
|
||||
stmt = stmt.order_by(AutoSession.created_at.desc())
|
||||
stmt = stmt.limit(page_size).offset((page - 1) * page_size)
|
||||
return list((await self.db.execute(stmt)).scalars().all())
|
||||
|
||||
async def get_session_detail(
|
||||
self, session_id: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""取会话详情(含动作列表与当前待决审批单)。"""
|
||||
session = await self.get_session(session_id)
|
||||
if session is None:
|
||||
return None
|
||||
act_stmt = select(AutoAction).where(AutoAction.session_id == session_id)
|
||||
actions = list((await self.db.execute(act_stmt)).scalars().all())
|
||||
actions.sort(key=lambda a: a.action_index)
|
||||
|
||||
ticket = None
|
||||
if session.current_action_id:
|
||||
tstmt = select(ApprovalTicket).where(
|
||||
ApprovalTicket.action_id == session.current_action_id,
|
||||
ApprovalTicket.status == "pending",
|
||||
)
|
||||
ticket = (await self.db.execute(tstmt)).scalar_one_or_none()
|
||||
return {"session": session, "actions": actions, "ticket": ticket}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 编排主流程
|
||||
# --------------------------------------------------------------------------
|
||||
async def start(self, session_id: str) -> None:
|
||||
"""编排:意图识别 → 场景校验 → 映射 → 计划 → 执行。"""
|
||||
session = await self.get_session(session_id)
|
||||
if session is None:
|
||||
logger.warning(f"start 会话不存在: {session_id}")
|
||||
return
|
||||
if session.status != "created":
|
||||
logger.info(f"会话已启动过,跳过: {session_id} status={session.status}")
|
||||
return
|
||||
|
||||
session.status = "running"
|
||||
await self.db.flush()
|
||||
await publish_progress(session.id, "start", "开始自动化处置")
|
||||
|
||||
description = (session.meta or {}).get("description", "")
|
||||
|
||||
# 1. 意图识别
|
||||
router = IntentRouter(self.db, audit=self.audit)
|
||||
try:
|
||||
intent = await router.detect(description, session.employee_id)
|
||||
except Exception as e: # noqa: BLE001
|
||||
intent = {"scenario_key": None, "confidence": 0.0, "error": str(e)}
|
||||
session.scenario_key = intent.get("scenario_key")
|
||||
session.confidence = float(intent.get("confidence") or 0.0)
|
||||
session.intent = intent
|
||||
await self.db.flush()
|
||||
await publish_progress(
|
||||
session.id,
|
||||
"intent",
|
||||
f"识别场景: {session.scenario_key or '未知'}(置信度 {session.confidence:.2f})",
|
||||
)
|
||||
|
||||
# 2. 置信度门槛 → 低置信度转人工
|
||||
thresholds = settings.get_automation_thresholds()
|
||||
confidence_min = float(thresholds.get("confidence_min", 0.6))
|
||||
if not session.scenario_key or session.confidence < confidence_min:
|
||||
await self._handoff(
|
||||
session, f"意图识别置信度不足({session.confidence:.2f}),转人工"
|
||||
)
|
||||
return
|
||||
|
||||
# 3. 场景配置校验
|
||||
scenario = await self._get_scenario_config(session.scenario_key)
|
||||
if scenario is None or not scenario.enabled:
|
||||
await self._handoff(
|
||||
session, f"场景未启用或未配置: {session.scenario_key}"
|
||||
)
|
||||
return
|
||||
|
||||
# 4. 终端映射(按需)
|
||||
mapping: Dict[str, Any] = {}
|
||||
if session.scenario_key in _SCENARIOS_NEED_TERMINAL:
|
||||
resolver = MappingResolver(self.db, audit=self.audit)
|
||||
mapping = await resolver.resolve(session.employee_id, session.scenario_key)
|
||||
if (
|
||||
session.scenario_key == "virus_dispose"
|
||||
and not mapping.get("client_ids")
|
||||
):
|
||||
await self._handoff(session, "未解析到目标终端,转人工")
|
||||
return
|
||||
session.meta = {**(session.meta or {}), "mapping": mapping}
|
||||
await self.db.flush()
|
||||
|
||||
# 5. 生成动作计划
|
||||
plan = self._build_actions(scenario, mapping)
|
||||
for idx, item in enumerate(plan):
|
||||
action = AutoAction(session_id=session.id, action_index=idx, **item)
|
||||
self.db.add(action)
|
||||
await self.db.flush()
|
||||
await publish_progress(
|
||||
session.id, "plan_ready", f"已生成处置方案(共 {len(plan)} 步)"
|
||||
)
|
||||
|
||||
# 6. 执行引擎
|
||||
executor = ActionExecutor(self.db, self.redis, audit=self.audit)
|
||||
await executor.run(session_id)
|
||||
|
||||
def _build_actions(
|
||||
self, scenario: ScenarioConfig, mapping: Dict[str, Any]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""从场景配置(或默认模板)生成动作计划。"""
|
||||
plan_items = scenario.actions
|
||||
if not plan_items:
|
||||
default = DEFAULT_SCENARIO_CONFIGS.get(scenario.scenario_key, {})
|
||||
plan_items = default.get("actions", [])
|
||||
|
||||
result: List[Dict[str, Any]] = []
|
||||
for it in plan_items:
|
||||
params: Dict[str, Any] = dict(it.get("params") or {})
|
||||
confirm_channel = it.get("confirm_channel")
|
||||
if mapping.get("client_ids"):
|
||||
params.setdefault("client_ids", mapping["client_ids"])
|
||||
if confirm_channel:
|
||||
params["confirm_channel"] = confirm_channel
|
||||
result.append(
|
||||
{
|
||||
"action_type": it.get("action_type", ""),
|
||||
"adapter": it.get("adapter", "internal"),
|
||||
"risk_level": it.get("risk_level", "read"),
|
||||
"title": it.get("title", it.get("action_type", "")),
|
||||
"description": it.get("description", it.get("title", "")),
|
||||
"payload": params,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
async def _get_scenario_config(
|
||||
self, scenario_key: str
|
||||
) -> Optional[ScenarioConfig]:
|
||||
"""加载场景配置(DB 优先,缺失则用默认模板的开关)。"""
|
||||
stmt = select(ScenarioConfig).where(
|
||||
ScenarioConfig.scenario_key == scenario_key
|
||||
)
|
||||
config = (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
if config is not None:
|
||||
return config
|
||||
# DB 无记录 → 用默认模板(默认启用)
|
||||
default = DEFAULT_SCENARIO_CONFIGS.get(scenario_key)
|
||||
if default is None:
|
||||
return None
|
||||
return ScenarioConfig(
|
||||
scenario_key=scenario_key,
|
||||
name=default.get("name", scenario_key),
|
||||
description=default.get("description", ""),
|
||||
enabled=default.get("enabled", True),
|
||||
trigger_conditions=default.get("trigger_conditions"),
|
||||
actions=default.get("actions"),
|
||||
approval_strategy=default.get("approval_strategy"),
|
||||
)
|
||||
|
||||
async def _handoff(self, session: AutoSession, reason: str) -> None:
|
||||
"""置为转人工并推送事件。"""
|
||||
session.status = "handoff"
|
||||
session.closed_by = "system(auto)"
|
||||
await self.db.flush()
|
||||
await publish_takeover(session.id, reason)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 转人工 / 反馈 / 关单
|
||||
# --------------------------------------------------------------------------
|
||||
async def takeover(
|
||||
self, session_id: str, agent_id: str, note: Optional[str] = None
|
||||
) -> AutoSession:
|
||||
"""转人工接管。"""
|
||||
session = await self.get_session(session_id)
|
||||
if session is None:
|
||||
raise AutomationException(AutomationErrorCode.SESSION_NOT_FOUND)
|
||||
session.status = "handoff"
|
||||
session.agent_id = agent_id
|
||||
session.closed_by = agent_id
|
||||
await self.db.flush()
|
||||
cancel_silent_close(session_id)
|
||||
await publish_takeover(session.id, f"坐席 {agent_id} 接管:{note or ''}")
|
||||
return session
|
||||
|
||||
async def resolve_feedback(
|
||||
self, session_id: str, satisfied: bool, note: Optional[str] = None
|
||||
) -> AutoSession:
|
||||
"""员工处置结果反馈。
|
||||
|
||||
满意 → 关单;不满意 → 转人工。
|
||||
"""
|
||||
session = await self.get_session(session_id)
|
||||
if session is None:
|
||||
raise AutomationException(AutomationErrorCode.SESSION_NOT_FOUND)
|
||||
cancel_silent_close(session_id)
|
||||
if satisfied:
|
||||
session.status = "closed"
|
||||
session.closed_by = session.employee_id
|
||||
else:
|
||||
session.status = "handoff"
|
||||
session.closed_by = "employee(reject)"
|
||||
await publish_takeover(session.id, f"员工不满意,转人工:{note or ''}")
|
||||
await self.db.flush()
|
||||
return session
|
||||
|
||||
async def auto_close(self, session_id: str) -> None:
|
||||
"""静默关单(仅 resolved 态可关)。"""
|
||||
session = await self.get_session(session_id)
|
||||
if session is None:
|
||||
return
|
||||
if session.status != "resolved":
|
||||
return
|
||||
session.status = "closed"
|
||||
session.closed_by = "system(auto)"
|
||||
await self.db.flush()
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 场景配置管理(管理端)
|
||||
# --------------------------------------------------------------------------
|
||||
async def list_scenario_configs(self) -> List[ScenarioConfig]:
|
||||
"""列出全部场景配置。"""
|
||||
stmt = select(ScenarioConfig).order_by(ScenarioConfig.scenario_key)
|
||||
return list((await self.db.execute(stmt)).scalars().all())
|
||||
|
||||
async def upsert_scenario_config(
|
||||
self, scenario_key: str, data: Dict[str, Any], operator: str = ""
|
||||
) -> ScenarioConfig:
|
||||
"""更新或创建场景配置,并快照为规则版本。"""
|
||||
from app.models.automation import RuleVersion
|
||||
|
||||
stmt = select(ScenarioConfig).where(
|
||||
ScenarioConfig.scenario_key == scenario_key
|
||||
)
|
||||
config = (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
if config is None:
|
||||
config = ScenarioConfig(scenario_key=scenario_key)
|
||||
self.db.add(config)
|
||||
|
||||
if data.get("name") is not None:
|
||||
config.name = data["name"]
|
||||
if data.get("description") is not None:
|
||||
config.description = data["description"]
|
||||
if data.get("enabled") is not None:
|
||||
config.enabled = data["enabled"]
|
||||
if data.get("trigger_conditions") is not None:
|
||||
config.trigger_conditions = data["trigger_conditions"]
|
||||
if data.get("actions") is not None:
|
||||
config.actions = data["actions"]
|
||||
if data.get("approval_strategy") is not None:
|
||||
config.approval_strategy = data["approval_strategy"]
|
||||
|
||||
await self.db.flush()
|
||||
|
||||
# 快照为规则版本
|
||||
last = (
|
||||
await self.db.execute(
|
||||
select(RuleVersion.version)
|
||||
.where(RuleVersion.scenario_key == scenario_key)
|
||||
.order_by(RuleVersion.version.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
new_version = (last or 0) + 1
|
||||
snapshot = RuleVersion(
|
||||
scenario_key=scenario_key,
|
||||
version=new_version,
|
||||
content={
|
||||
"actions": config.actions,
|
||||
"approval_strategy": config.approval_strategy,
|
||||
"trigger_conditions": config.trigger_conditions,
|
||||
},
|
||||
status="published",
|
||||
canary_percent=100,
|
||||
created_by=operator or "admin",
|
||||
remark=f"更新场景配置至 v{new_version}",
|
||||
)
|
||||
self.db.add(snapshot)
|
||||
await self.db.flush()
|
||||
config.current_version_id = snapshot.id
|
||||
await self.db.flush()
|
||||
return config
|
||||
|
||||
async def list_rule_versions(
|
||||
self, scenario_key: Optional[str] = None
|
||||
) -> List[RuleVersion]:
|
||||
"""列出规则版本。"""
|
||||
stmt = select(RuleVersion)
|
||||
if scenario_key:
|
||||
stmt = stmt.where(RuleVersion.scenario_key == scenario_key)
|
||||
stmt = stmt.order_by(RuleVersion.created_at.desc())
|
||||
return list((await self.db.execute(stmt)).scalars().all())
|
||||
|
||||
async def metrics(self) -> Dict[str, Any]:
|
||||
"""汇总看板指标。"""
|
||||
from sqlalchemy import func
|
||||
|
||||
total = (
|
||||
await self.db.execute(select(func.count(AutoSession.id)))
|
||||
).scalar() or 0
|
||||
resolved = (
|
||||
await self.db.execute(
|
||||
select(func.count(AutoSession.id)).where(
|
||||
AutoSession.status == "resolved"
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
handoff = (
|
||||
await self.db.execute(
|
||||
select(func.count(AutoSession.id)).where(
|
||||
AutoSession.status == "handoff"
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
error = (
|
||||
await self.db.execute(
|
||||
select(func.count(AutoSession.id)).where(
|
||||
AutoSession.status == "error"
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
auto_actions = (
|
||||
await self.db.execute(
|
||||
select(func.count(AutoAction.id)).where(
|
||||
AutoAction.status == "success",
|
||||
AutoAction.risk_level.in_(["read", "low"]),
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
approval_actions = (
|
||||
await self.db.execute(
|
||||
select(func.count(AutoAction.id)).where(
|
||||
AutoAction.risk_level == "high"
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
by_scenario_rows = (
|
||||
await self.db.execute(
|
||||
select(AutoSession.scenario_key, func.count(AutoSession.id)).group_by(
|
||||
AutoSession.scenario_key
|
||||
)
|
||||
)
|
||||
).all()
|
||||
by_scenario = {k: v for k, v in by_scenario_rows if k}
|
||||
|
||||
return {
|
||||
"total_sessions": total,
|
||||
"resolved_sessions": resolved,
|
||||
"handoff_sessions": handoff,
|
||||
"error_sessions": error,
|
||||
"auto_executed_actions": auto_actions,
|
||||
"approval_required_actions": approval_actions,
|
||||
"by_scenario": by_scenario,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 后台编排入口
|
||||
# --------------------------------------------------------------------------
|
||||
async def run_session_in_background(session_id: str) -> None:
|
||||
"""后台运行会话编排(独立 DB 会话,结束时提交/回滚)。"""
|
||||
factory = _get_session_factory()
|
||||
async with factory() as db:
|
||||
svc = AutoSessionService(db)
|
||||
try:
|
||||
await svc.start(session_id)
|
||||
await db.commit()
|
||||
except AutomationException as e:
|
||||
await db.rollback()
|
||||
logger.warning(f"编排业务异常 session={session_id}: {e.message}")
|
||||
# 标记转人工
|
||||
try:
|
||||
session = await svc.get_session(session_id)
|
||||
if session and session.status not in ("closed", "handoff"):
|
||||
session.status = "handoff"
|
||||
session.closed_by = "system(auto)"
|
||||
await db.commit()
|
||||
except Exception: # noqa: BLE001
|
||||
await db.rollback()
|
||||
except Exception as e: # noqa: BLE001
|
||||
await db.rollback()
|
||||
logger.error(f"编排未预期异常 session={session_id}: {e}")
|
||||
try:
|
||||
session = await svc.get_session(session_id)
|
||||
if session and session.status not in ("closed", "handoff"):
|
||||
session.status = "handoff"
|
||||
session.closed_by = "system(auto)"
|
||||
await db.commit()
|
||||
except Exception: # noqa: BLE001
|
||||
await db.rollback()
|
||||
Reference in New Issue
Block a user