WIP-CHECKPOINT[auth-refactor]: 固化工程师崩溃前部分成果 + 同树其他未提交WIP(仅源码,不含密钥/二进制)-- 待重激活工程师续作

This commit is contained in:
Simon
2026-07-07 21:52:11 +08:00
parent 242c1967ff
commit fab75760e0
203 changed files with 21504 additions and 3345 deletions
+168
View File
@@ -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
+285
View File
@@ -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_cacheTTL),降低外部系统压力。
# =============================================================================
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()