WIP-CHECKPOINT[auth-refactor]: 固化工程师崩溃前部分成果 + 同树其他未提交WIP(仅源码,不含密钥/二进制)-- 待重激活工程师续作
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 管理员用户服务
|
||||
# =============================================================================
|
||||
# 说明:管理员用户的 CRUD 操作服务
|
||||
# =============================================================================
|
||||
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import bcrypt
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.agent import Agent
|
||||
from app.utils.error_codes import ErrorCode
|
||||
from app.utils.response import AppException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminUserService:
|
||||
"""管理员用户服务。
|
||||
|
||||
提供管理员用户的增删改查、密码验证等功能。
|
||||
"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
"""初始化服务。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
async def get_user_by_user_id(self, user_id: str) -> Optional[Agent]:
|
||||
"""根据 user_id 查询管理员用户。
|
||||
|
||||
Args:
|
||||
user_id: 企微用户ID
|
||||
|
||||
Returns:
|
||||
Optional[Agent]: 管理员用户,不存在返回 None
|
||||
"""
|
||||
stmt = select(Agent).where(
|
||||
Agent.user_id == user_id,
|
||||
Agent.role.in_(["admin", "super_admin"]),
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_user_by_id(self, id: str) -> Optional[Agent]:
|
||||
"""根据 ID 查询管理员用户。
|
||||
|
||||
Args:
|
||||
id: 用户ID
|
||||
|
||||
Returns:
|
||||
Optional[Agent]: 管理员用户,不存在返回 None
|
||||
"""
|
||||
stmt = select(Agent).where(
|
||||
Agent.id == id,
|
||||
Agent.role.in_(["admin", "super_admin"]),
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalars().first()
|
||||
|
||||
async def list_admin_users(
|
||||
self,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> Tuple[List[Agent], int]:
|
||||
"""查询管理员用户列表。
|
||||
|
||||
Args:
|
||||
page: 页码(从1开始)
|
||||
page_size: 每页数量
|
||||
is_active: 按激活状态过滤
|
||||
|
||||
Returns:
|
||||
Tuple[List[Agent], int]: (用户列表, 总数)
|
||||
"""
|
||||
# 构建查询
|
||||
stmt = select(Agent).where(Agent.role.in_(["admin", "super_admin"]))
|
||||
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(Agent.status == ("online" if is_active else "offline"))
|
||||
|
||||
# 统计总数
|
||||
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||
total_result = await self.db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 分页查询
|
||||
stmt = stmt.order_by(Agent.created_at.desc())
|
||||
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
result = await self.db.execute(stmt)
|
||||
items = list(result.scalars().all())
|
||||
|
||||
return items, total
|
||||
|
||||
async def create_admin_user(
|
||||
self,
|
||||
user_id: str,
|
||||
name: str,
|
||||
role: str = "admin",
|
||||
password: Optional[str] = None,
|
||||
) -> Agent:
|
||||
"""创建管理员用户。
|
||||
|
||||
Args:
|
||||
user_id: 企微用户ID
|
||||
name: 姓名
|
||||
role: 角色(admin/super_admin)
|
||||
password: 初始密码(可选,不传则生成随机密码)
|
||||
|
||||
Returns:
|
||||
Agent: 创建的用户
|
||||
|
||||
Raises:
|
||||
AppException: 用户已存在
|
||||
"""
|
||||
# 检查是否已存在
|
||||
existing = await self.get_user_by_user_id(user_id)
|
||||
if existing:
|
||||
raise AppException(ErrorCode.INVALID_PARAMETER, f"用户 {user_id} 已存在")
|
||||
|
||||
# 生成密码
|
||||
if not password:
|
||||
password = secrets.token_urlsafe(12) # 生成随机密码
|
||||
|
||||
# 密码哈希
|
||||
password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
# 创建用户
|
||||
agent = Agent(
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
role=role,
|
||||
status="offline",
|
||||
password_hash=password_hash,
|
||||
current_load=0,
|
||||
max_load=5,
|
||||
)
|
||||
self.db.add(agent)
|
||||
await self.db.flush()
|
||||
|
||||
logger.info(f"创建管理员用户: user_id={user_id}, role={role}")
|
||||
|
||||
return agent
|
||||
|
||||
async def update_admin_user(
|
||||
self,
|
||||
id: str,
|
||||
name: Optional[str] = None,
|
||||
role: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> Agent:
|
||||
"""更新管理员用户。
|
||||
|
||||
Args:
|
||||
id: 用户ID
|
||||
name: 姓名(可选)
|
||||
role: 角色(可选)
|
||||
is_active: 是否激活(可选)
|
||||
|
||||
Returns:
|
||||
Agent: 更新后的用户
|
||||
|
||||
Raises:
|
||||
AppException: 用户不存在
|
||||
"""
|
||||
agent = await self.get_user_by_id(id)
|
||||
if not agent:
|
||||
raise AppException(ErrorCode.NOT_FOUND, "用户不存在")
|
||||
|
||||
if name is not None:
|
||||
agent.name = name
|
||||
if role is not None:
|
||||
agent.role = role
|
||||
if is_active is not None:
|
||||
agent.status = "online" if is_active else "offline"
|
||||
|
||||
agent.updated_at = datetime.now()
|
||||
self.db.add(agent)
|
||||
await self.db.flush()
|
||||
|
||||
logger.info(f"更新管理员用户: id={id}")
|
||||
|
||||
return agent
|
||||
|
||||
async def delete_admin_user(self, id: str) -> bool:
|
||||
"""删除管理员用户。
|
||||
|
||||
Args:
|
||||
id: 用户ID
|
||||
|
||||
Returns:
|
||||
bool: 是否删除成功
|
||||
|
||||
Raises:
|
||||
AppException: 用户不存在或无法删除超级管理员
|
||||
"""
|
||||
agent = await self.get_user_by_id(id)
|
||||
if not agent:
|
||||
raise AppException(ErrorCode.NOT_FOUND, "用户不存在")
|
||||
|
||||
# 不允许删除超级管理员
|
||||
if agent.role == "super_admin":
|
||||
raise AppException(ErrorCode.FORBIDDEN, "无法删除超级管理员")
|
||||
|
||||
await self.db.delete(agent)
|
||||
await self.db.flush()
|
||||
|
||||
logger.info(f"删除管理员用户: id={id}")
|
||||
|
||||
return True
|
||||
|
||||
async def reset_password(self, id: str, new_password: str) -> Agent:
|
||||
"""重置密码。
|
||||
|
||||
Args:
|
||||
id: 用户ID
|
||||
new_password: 新密码
|
||||
|
||||
Returns:
|
||||
Agent: 更新后的用户
|
||||
|
||||
Raises:
|
||||
AppException: 用户不存在
|
||||
"""
|
||||
agent = await self.get_user_by_id(id)
|
||||
if not agent:
|
||||
raise AppException(ErrorCode.NOT_FOUND, "用户不存在")
|
||||
|
||||
# 密码哈希
|
||||
agent.password_hash = bcrypt.hashpw(new_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
agent.updated_at = datetime.now()
|
||||
self.db.add(agent)
|
||||
await self.db.flush()
|
||||
|
||||
logger.info(f"重置密码: id={id}")
|
||||
|
||||
return agent
|
||||
|
||||
async def verify_password(self, user_id: str, password: str) -> Optional[Agent]:
|
||||
"""验证密码。
|
||||
|
||||
Args:
|
||||
user_id: 企微用户ID
|
||||
password: 密码
|
||||
|
||||
Returns:
|
||||
Optional[Agent]: 验证成功返回用户,否则返回 None
|
||||
"""
|
||||
agent = await self.get_user_by_user_id(user_id)
|
||||
if not agent:
|
||||
return None
|
||||
|
||||
# 检查密码
|
||||
if not agent.password_hash:
|
||||
return None
|
||||
|
||||
if not bcrypt.checkpw(password.encode("utf-8"), agent.password_hash.encode("utf-8")):
|
||||
return None
|
||||
|
||||
return agent
|
||||
|
||||
|
||||
async def init_super_admin(db: AsyncSession) -> Optional[Agent]:
|
||||
"""初始化超级管理员。
|
||||
|
||||
从环境变量读取配置,创建超级管理员用户(如果不存在)。
|
||||
|
||||
环境变量:
|
||||
ADMIN_USERNAME: 超级管理员用户名(必填)
|
||||
ADMIN_PASSWORD: 超级管理员密码(必填)
|
||||
ADMIN_NAME: 超级管理员姓名(可选,默认"超级管理员")
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Optional[Agent]: 创建/已有的超级管理员用户
|
||||
"""
|
||||
import os
|
||||
|
||||
admin_username = os.getenv("ADMIN_USERNAME")
|
||||
admin_password = os.getenv("ADMIN_PASSWORD")
|
||||
admin_name = os.getenv("ADMIN_NAME", "超级管理员")
|
||||
|
||||
if not admin_username or not admin_password:
|
||||
logger.info("未配置超级管理员,跳过初始化")
|
||||
return None
|
||||
|
||||
service = AdminUserService(db)
|
||||
|
||||
# 检查是否已存在
|
||||
existing = await service.get_user_by_user_id(admin_username)
|
||||
if existing:
|
||||
logger.info(f"超级管理员已存在: user_id={admin_username}")
|
||||
return existing
|
||||
|
||||
# 创建超级管理员
|
||||
agent = await service.create_admin_user(
|
||||
user_id=admin_username,
|
||||
name=admin_name,
|
||||
role="super_admin",
|
||||
password=admin_password,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"超级管理员初始化成功: user_id={admin_username}")
|
||||
|
||||
return agent
|
||||
@@ -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()
|
||||
@@ -0,0 +1,91 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 员工头像同步服务
|
||||
# =============================================================================
|
||||
# 说明:集中处理"从企微拿到头像 URL 后,更新 employee 表 + 清 Redis 缓存"的逻辑,
|
||||
# 确保所有登录/认证路径(H5 OAuth、坐席密码、扫码确认、dev 登录)行为一致,
|
||||
# 避免部分路径漏清缓存导致前端仍是旧图(要求 C:全路径一致性)。
|
||||
#
|
||||
# 设计原则:
|
||||
# 1. 头像更新失败绝不阻塞登录主流程 → 所有异常内部吞掉并记录 warning。
|
||||
# 2. 复用已有写法:先查 Employee,有则 update 字段,无则跳过(创建由各自登录逻辑负责)。
|
||||
# 3. 统一清理 Redis 缓存 key = employee:avatar:{employee_id},强制后续读库取最新。
|
||||
# 4. 清理企微头像 URL 的多余查询参数,保留稳定部分,降低 404 / 过期概率(要求 B)。
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.employee import Employee
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def clean_avatar_url(url: str) -> str:
|
||||
"""清理企微头像 URL,去掉查询字符串(? 及其之后),保留稳定部分。
|
||||
|
||||
企微头像 URL 形如 https://wework.qpic.cn/...png?...,常带尺寸 / 时效参数,
|
||||
去参后链接更稳定、不易因时效参数失效而 404。
|
||||
|
||||
Args:
|
||||
url: 原始头像 URL
|
||||
|
||||
Returns:
|
||||
str: 去参后的稳定 URL;空输入返回空串。
|
||||
"""
|
||||
if not url:
|
||||
return ""
|
||||
return url.split("?", 1)[0]
|
||||
|
||||
|
||||
async def sync_employee_avatar(
|
||||
db: AsyncSession,
|
||||
redis_client: Optional[object],
|
||||
employee_id: str,
|
||||
avatar: str,
|
||||
) -> None:
|
||||
"""用企微返回的头像 URL 同步 employee 表并清缓存。
|
||||
|
||||
任一异常都内部处理,不向上抛出(头像更新失败绝不能阻塞登录)。
|
||||
|
||||
Args:
|
||||
db: 数据库会话(本函数内部会 commit 一次以落库)
|
||||
redis_client: Redis 客户端(可为 None,此时跳过清缓存)
|
||||
employee_id: 企微 userid
|
||||
avatar: 企微返回的头像 URL(空字符串表示无头像,不更新)
|
||||
"""
|
||||
# 清理多余查询参数,保留稳定部分
|
||||
avatar = clean_avatar_url(avatar)
|
||||
if not avatar:
|
||||
# 企微未返回头像时不做任何写入,避免把已有头像清空
|
||||
return
|
||||
|
||||
try:
|
||||
stmt = select(Employee).where(
|
||||
Employee.employee_id == employee_id,
|
||||
Employee.corp_id == settings.wecom_corp_id,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
employee = result.scalars().first()
|
||||
if employee:
|
||||
employee.avatar = avatar
|
||||
employee.avatar_updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
logger.info(f"同步员工头像: employee_id={employee_id}")
|
||||
else:
|
||||
logger.debug(f"员工不存在,跳过头像同步: employee_id={employee_id}")
|
||||
|
||||
# 删除 Redis 头像缓存,强制后续读取数据库最新头像
|
||||
if redis_client is not None:
|
||||
try:
|
||||
await redis_client.delete(f"employee:avatar:{employee_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"删除头像Redis缓存失败: employee_id={employee_id}, error={e}")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"同步员工头像失败(不阻塞登录): employee_id={employee_id}, error={e}"
|
||||
)
|
||||
@@ -12,9 +12,9 @@ from typing import List, Optional, Tuple
|
||||
|
||||
from wordfilter import Wordfilter
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
import logging
|
||||
|
||||
logger = get_logger(__name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ModerationAction(str, Enum):
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 知识库自动迭代服务
|
||||
# =============================================================================
|
||||
# 说明:知识库自动迭代核心服务
|
||||
# 功能:
|
||||
# 1. 分析错误标注的高频问题
|
||||
# 2. 查找未命中知识库的会话
|
||||
# 3. 生成优化建议
|
||||
# 4. 审核通过后应用到知识库
|
||||
# 5. 推送审核通知给管理员
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.knowledge_base import KnowledgeBase
|
||||
from app.models.knowledge_suggestion import KnowledgeSuggestion
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.conversation_annotation import ConversationAnnotation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KnowledgeIterationService:
|
||||
"""知识库自动迭代服务。
|
||||
|
||||
分析会话标注和会话数据,生成知识库优化建议,
|
||||
支持管理员审核后自动应用到知识库。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化服务。"""
|
||||
# AI 分析 API(复用 Dify)
|
||||
self.ai_api_url = settings.dify_wingman_api_url
|
||||
self.ai_api_key = settings.dify_wingman_api_key
|
||||
self.ai_timeout = settings.dify_wingman_timeout
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 核心方法:分析并生成建议
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def analyze_and_generate_suggestions(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
days: int = 7,
|
||||
) -> Dict[str, Any]:
|
||||
"""分析并生成知识库优化建议。
|
||||
|
||||
分析过去N天的标注数据和会话数据,生成优化建议。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
days: 分析过去N天的数据,默认7天
|
||||
|
||||
Returns:
|
||||
Dict: {
|
||||
"annotations_analyzed": int, # 分析的标注数量
|
||||
"conversations_analyzed": int, # 分析的会话数量
|
||||
"suggestions_generated": int, # 生成的建议数量
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"annotations_analyzed": 0,
|
||||
"conversations_analyzed": 0,
|
||||
"suggestions_generated": 0,
|
||||
}
|
||||
|
||||
# 1. 分析错误标注的高频问题
|
||||
annotation_suggestions = await self._analyze_annotation_data(db, days)
|
||||
result["annotations_analyzed"] = annotation_suggestions.get("analyzed_count", 0)
|
||||
result["suggestions_generated"] += annotation_suggestions.get(
|
||||
"suggestions_count", 0
|
||||
)
|
||||
|
||||
# 2. 分析未命中知识库的会话
|
||||
conversation_suggestions = await self._analyze_conversation_data(db, days)
|
||||
result["conversations_analyzed"] = conversation_suggestions.get(
|
||||
"analyzed_count", 0
|
||||
)
|
||||
result["suggestions_generated"] += conversation_suggestions.get(
|
||||
"suggestions_count", 0
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"知识库迭代分析完成: "
|
||||
f"标注={result['annotations_analyzed']}, "
|
||||
f"会话={result['conversations_analyzed']}, "
|
||||
f"建议={result['suggestions_generated']}"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def _analyze_annotation_data(
|
||||
self, db: AsyncSession, days: int
|
||||
) -> Dict[str, Any]:
|
||||
"""分析标注数据,生成优化建议。
|
||||
|
||||
查找被标记为"无用"的AI回复,分析高频错误原因,
|
||||
尝试生成FAQ更新建议。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
days: 分析过去N天的数据
|
||||
|
||||
Returns:
|
||||
Dict: 分析结果统计
|
||||
"""
|
||||
since = datetime.now() - timedelta(days=days)
|
||||
|
||||
# 查询过去N天的无效标注
|
||||
stmt = (
|
||||
select(ConversationAnnotation)
|
||||
.where(ConversationAnnotation.feedback == "useless")
|
||||
.where(ConversationAnnotation.created_at >= since)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
annotations = result.scalars().all()
|
||||
|
||||
if not annotations:
|
||||
return {"analyzed_count": 0, "suggestions_count": 0}
|
||||
|
||||
# 按被标注的消息ID分组,统计高频错误
|
||||
message_error_counts: Dict[str, int] = {}
|
||||
for ann in annotations:
|
||||
msg_id = ann.message_id
|
||||
message_error_counts[msg_id] = message_error_counts.get(msg_id, 0) + 1
|
||||
|
||||
# 找出高频错误(被标注3次以上)
|
||||
frequent_errors = {
|
||||
msg_id: count
|
||||
for msg_id, count in message_error_counts.items()
|
||||
if count >= 3
|
||||
}
|
||||
|
||||
if not frequent_errors:
|
||||
return {"analyzed_count": len(annotations), "suggestions_count": 0}
|
||||
|
||||
# 调用AI分析错误模式,生成更新建议
|
||||
suggestions_count = 0
|
||||
for msg_id, error_count in frequent_errors.items():
|
||||
# 生成优化建议
|
||||
suggestion = await self._generate_update_suggestion(
|
||||
db=db,
|
||||
source_type="annotation",
|
||||
source_data=[msg_id],
|
||||
reason=f"该AI回复在过去{days}天内被标记为无用{error_count}次",
|
||||
)
|
||||
if suggestion:
|
||||
db.add(suggestion)
|
||||
suggestions_count += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"analyzed_count": len(annotations), "suggestions_count": suggestions_count}
|
||||
|
||||
async def _analyze_conversation_data(
|
||||
self, db: AsyncSession, days: int
|
||||
) -> Dict[str, Any]:
|
||||
"""分析会话数据,生成新增FAQ建议。
|
||||
|
||||
查找AI无法解决(转人工)的会话,分析生成新FAQ建议。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
days: 分析过去N天的数据
|
||||
|
||||
Returns:
|
||||
Dict: 分析结果统计
|
||||
"""
|
||||
since = datetime.now() - timedelta(days=days)
|
||||
|
||||
# 查询过去N天的AI未解决会话(转人工的会话)
|
||||
stmt = (
|
||||
select(Conversation)
|
||||
.where(Conversation.created_at >= since)
|
||||
.where(Conversation.status.in_(["waiting_agent", "agentServing"]))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
conversations = result.scalars().all()
|
||||
|
||||
if not conversations:
|
||||
return {"analyzed_count": 0, "suggestions_count": 0}
|
||||
|
||||
# 抽样分析(避免一次性处理太多)
|
||||
sample_size = min(20, len(conversations))
|
||||
sampled = conversations[:sample_size]
|
||||
|
||||
# 对每个会话进行分析
|
||||
suggestions_count = 0
|
||||
for conv in sampled:
|
||||
# 检查是否已存在类似建议
|
||||
existing = await self._check_existing_suggestion(db, conv.id)
|
||||
if existing:
|
||||
continue
|
||||
|
||||
# 生成新FAQ建议
|
||||
suggestion = await self._generate_new_faq_suggestion(
|
||||
db=db,
|
||||
source_type="conversation",
|
||||
source_data=[conv.id],
|
||||
reason=f"会话'{conv.id}'中AI未能解决问题,需人工介入",
|
||||
)
|
||||
if suggestion:
|
||||
db.add(suggestion)
|
||||
suggestions_count += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"analyzed_count": len(conversations), "suggestions_count": suggestions_count}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 辅助方法
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def _generate_update_suggestion(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
source_type: str,
|
||||
source_data: List[str],
|
||||
reason: str,
|
||||
) -> Optional[KnowledgeSuggestion]:
|
||||
"""生成知识库更新建议。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
source_type: 来源类型
|
||||
source_data: 来源数据
|
||||
reason: 生成理由
|
||||
|
||||
Returns:
|
||||
Optional[KnowledgeSuggestion]: 建议对象
|
||||
"""
|
||||
# TODO: 调用AI生成具体的更新内容
|
||||
# 当前返回示例数据,实际应调用 Dify API
|
||||
|
||||
return KnowledgeSuggestion(
|
||||
suggestion_type="update",
|
||||
status="pending",
|
||||
title="[待AI生成] 优化建议",
|
||||
content="请通过AI分析生成具体的更新内容",
|
||||
category="其他",
|
||||
tags=[],
|
||||
source_type=source_type,
|
||||
source_data=source_data,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
async def _generate_new_faq_suggestion(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
source_type: str,
|
||||
source_data: List[str],
|
||||
reason: str,
|
||||
) -> Optional[KnowledgeSuggestion]:
|
||||
"""生成新FAQ建议。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
source_type: 来源类型
|
||||
source_data: 来源数据
|
||||
reason: 生成理由
|
||||
|
||||
Returns:
|
||||
Optional[KnowledgeSuggestion]: 建议对象
|
||||
"""
|
||||
# TODO: 调用AI生成具体的FAQ内容
|
||||
# 当前返回示例数据,实际应调用 Dify API
|
||||
|
||||
return KnowledgeSuggestion(
|
||||
suggestion_type="new_faq",
|
||||
status="pending",
|
||||
title="[待AI生成] 新FAQ建议",
|
||||
content="请通过AI分析生成具体的问题和答案",
|
||||
category="其他",
|
||||
tags=[],
|
||||
source_type=source_type,
|
||||
source_data=source_data,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
async def _check_existing_suggestion(
|
||||
self, db: AsyncSession, source_id: str
|
||||
) -> bool:
|
||||
"""检查是否已存在相关建议。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
source_id: 来源ID
|
||||
|
||||
Returns:
|
||||
bool: 是否已存在
|
||||
"""
|
||||
stmt = (
|
||||
select(KnowledgeSuggestion)
|
||||
.where(KnowledgeSuggestion.status == "pending")
|
||||
.where(KnowledgeSuggestion.source_data.contains(source_id))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing = result.scalars().first()
|
||||
return existing is not None
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 审核与应用
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def approve_suggestion(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
suggestion_id: str,
|
||||
reviewer_id: str,
|
||||
) -> Optional[KnowledgeSuggestion]:
|
||||
"""审核通过建议并应用到知识库。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
suggestion_id: 建议ID
|
||||
reviewer_id: 审核人ID
|
||||
|
||||
Returns:
|
||||
Optional[KnowledgeSuggestion]: 更新后的建议对象
|
||||
"""
|
||||
# 获取建议
|
||||
stmt = select(KnowledgeSuggestion).where(
|
||||
KnowledgeSuggestion.id == suggestion_id
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
suggestion = result.scalar_one_or_none()
|
||||
|
||||
if not suggestion:
|
||||
return None
|
||||
|
||||
# 更新状态
|
||||
suggestion.status = "approved"
|
||||
suggestion.reviewer_id = reviewer_id
|
||||
suggestion.reviewed_at = datetime.now()
|
||||
|
||||
# 如果是新FAQ或更新,创建对应的知识库条目
|
||||
if suggestion.suggestion_type in ("new_faq", "update"):
|
||||
kb = KnowledgeBase(
|
||||
title=suggestion.title,
|
||||
content=suggestion.content,
|
||||
category=suggestion.category,
|
||||
tags=suggestion.tags,
|
||||
)
|
||||
db.add(kb)
|
||||
suggestion.status = "applied"
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(suggestion)
|
||||
|
||||
logger.info(f"建议已审核通过并应用: {suggestion_id}")
|
||||
return suggestion
|
||||
|
||||
async def reject_suggestion(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
suggestion_id: str,
|
||||
reviewer_id: str,
|
||||
reject_reason: str,
|
||||
) -> Optional[KnowledgeSuggestion]:
|
||||
"""拒绝建议。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
suggestion_id: 建议ID
|
||||
reviewer_id: 审核人ID
|
||||
reject_reason: 拒绝理由
|
||||
|
||||
Returns:
|
||||
Optional[KnowledgeSuggestion]: 更新后的建议对象
|
||||
"""
|
||||
stmt = select(KnowledgeSuggestion).where(
|
||||
KnowledgeSuggestion.id == suggestion_id
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
suggestion = result.scalar_one_or_none()
|
||||
|
||||
if not suggestion:
|
||||
return None
|
||||
|
||||
suggestion.status = "rejected"
|
||||
suggestion.reviewer_id = reviewer_id
|
||||
suggestion.reviewed_at = datetime.now()
|
||||
suggestion.reject_reason = reject_reason
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(suggestion)
|
||||
|
||||
logger.info(f"建议已拒绝: {suggestion_id}, 理由: {reject_reason}")
|
||||
return suggestion
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 查询统计
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def get_suggestion_stats(self, db: AsyncSession) -> Dict[str, int]:
|
||||
"""获取建议统计。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统计数据
|
||||
"""
|
||||
# 总数
|
||||
stmt = select(KnowledgeSuggestion)
|
||||
result = await db.execute(stmt)
|
||||
all_suggestions = result.scalars().all()
|
||||
|
||||
stats = {
|
||||
"total": len(all_suggestions),
|
||||
"pending": 0,
|
||||
"approved": 0,
|
||||
"rejected": 0,
|
||||
"applied": 0,
|
||||
"new_faq_count": 0,
|
||||
"update_count": 0,
|
||||
"outdated_count": 0,
|
||||
}
|
||||
|
||||
for s in all_suggestions:
|
||||
if s.status in stats:
|
||||
stats[s.status] += 1
|
||||
if s.suggestion_type == "new_faq":
|
||||
stats["new_faq_count"] += 1
|
||||
elif s.suggestion_type == "update":
|
||||
stats["update_count"] += 1
|
||||
elif s.suggestion_type == "outdated":
|
||||
stats["outdated_count"] += 1
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
# 依赖注入函数
|
||||
async def dep_knowledge_iteration_service() -> KnowledgeIterationService:
|
||||
"""获取知识库迭代服务实例。"""
|
||||
return KnowledgeIterationService()
|
||||
@@ -612,6 +612,27 @@ class MessageRouter:
|
||||
self.db.add(conversation)
|
||||
await self.db.flush() # 刷新以获取生成的 ID
|
||||
|
||||
# 创建会话后,尝试从 employees 表获取员工信息(作为回退)
|
||||
try:
|
||||
from app.models.employee import Employee
|
||||
stmt = select(Employee).where(Employee.employee_id == employee_id)
|
||||
result = await self.db.execute(stmt)
|
||||
employee = result.scalars().first()
|
||||
if employee and employee.name:
|
||||
conversation.employee_name = employee.name
|
||||
conversation.department = employee.department or ""
|
||||
conversation.position = employee.position or ""
|
||||
conversation.level = employee.level or ""
|
||||
logger.info(
|
||||
f"从employees表获取员工信息: employee_id={employee_id}, "
|
||||
f"name={employee.name}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"从employees表获取员工信息失败: employee_id={employee_id}, "
|
||||
f"error={e}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"创建新会话: conv_id={conversation.id}, "
|
||||
f"employee_id={employee_id}, status=ai_handling"
|
||||
@@ -669,3 +690,26 @@ class MessageRouter:
|
||||
f"VIP检测失败(不阻塞流程): employee_id={conversation.employee_id}, "
|
||||
f"error={e}"
|
||||
)
|
||||
# 企微API失败时,从employees表回退获取员工信息
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
from app.models.employee import Employee
|
||||
stmt = select(Employee).where(
|
||||
Employee.employee_id == conversation.employee_id
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
employee = result.scalars().first()
|
||||
if employee and employee.name:
|
||||
conversation.employee_name = employee.name
|
||||
conversation.department = employee.department or ""
|
||||
conversation.position = employee.position or ""
|
||||
conversation.level = employee.level or ""
|
||||
logger.info(
|
||||
f"从employees表回退获取员工信息: employee_id={conversation.employee_id}, "
|
||||
f"name={employee.name}"
|
||||
)
|
||||
except Exception as fallback_error:
|
||||
logger.warning(
|
||||
f"从employees表回退获取员工信息失败: employee_id={conversation.employee_id}, "
|
||||
f"error={fallback_error}"
|
||||
)
|
||||
|
||||
@@ -205,10 +205,14 @@ class QrcodeService:
|
||||
|
||||
优先使用 settings.wecom_sso_callback_base + /api/auth_qrcode/scan 拼接完整 URL。
|
||||
企微要求 redirect_uri 必须是完整的可信域名 URL,不能用相对路径。
|
||||
如果未配置 wecom_sso_callback_base,则抛出异常提醒配置。
|
||||
如果未配置 wecom_sso_callback_base,则尝试读取环境变量作为兜底。
|
||||
"""
|
||||
import os
|
||||
# 优先使用 wecom_sso_callback_base 构建完整 URL
|
||||
callback_base = getattr(settings, "wecom_sso_callback_base", "")
|
||||
if not callback_base:
|
||||
# 兜底: 读环境变量
|
||||
callback_base = os.getenv("WECOM_SSO_CALLBACK_BASE", "")
|
||||
if callback_base:
|
||||
# 去除末尾斜杠,确保路径正确拼接
|
||||
base = callback_base.rstrip("/")
|
||||
@@ -249,25 +253,28 @@ class QrcodeService:
|
||||
logger.warning(f"扫码失败: ticket 已过期或不存在 ticket={ticket[:8]}...")
|
||||
raise ValueError("扫码票据已过期或不存在")
|
||||
|
||||
# 2. 获取用户身份
|
||||
# 2. 获取用户身份(含企微头像 URL,供 confirm 时落库)
|
||||
employee_id = ""
|
||||
name = ""
|
||||
avatar = ""
|
||||
if _dev_mode_enabled():
|
||||
# dev 模式: 用预设 dev 用户
|
||||
# 提取 code 中的 userid(约定 dev 模式下 code 形如 "dev:dev-user-001")
|
||||
employee_id, name = self._dev_extract_user(code)
|
||||
employee_id, name, avatar = self._dev_extract_user(code)
|
||||
logger.info(
|
||||
f"[DEV] 扫码回调模拟: ticket={ticket[:8]}..., "
|
||||
f"employee_id={employee_id}, name={name}"
|
||||
)
|
||||
else:
|
||||
# 生产模式: 调企微 OAuth API
|
||||
employee_id, name = await self._fetch_oauth_user(code)
|
||||
employee_id, name, avatar = await self._fetch_oauth_user(code)
|
||||
|
||||
# 3. 写 Redis 扫码结果(TTL 120s,等待 confirm 端点消费)
|
||||
# avatar 一并写入,confirm 时无需再次调用企微 API 即可同步头像
|
||||
scan_payload = {
|
||||
"employee_id": employee_id,
|
||||
"name": name,
|
||||
"avatar": avatar,
|
||||
"scanned_at": datetime.now().isoformat(),
|
||||
}
|
||||
await self.redis.setex(
|
||||
@@ -303,9 +310,9 @@ class QrcodeService:
|
||||
"""
|
||||
# dev 模式预设用户表(与 dev_auth.py 保持一致)
|
||||
DEV_USERS = {
|
||||
"dev-user-001": ("dev-user-001", "张三(普通员工)"),
|
||||
"dev-agent-001": ("dev-agent-001", "李四(IT 坐席)"),
|
||||
"dev-admin-001": ("dev-admin-001", "钱七(系统管理员)"),
|
||||
"dev-user-001": ("dev-user-001", "张三(普通员工)", ""),
|
||||
"dev-agent-001": ("dev-agent-001", "李四(IT 坐席)", ""),
|
||||
"dev-admin-001": ("dev-admin-001", "钱七(系统管理员)", ""),
|
||||
}
|
||||
|
||||
if code.startswith("dev:"):
|
||||
@@ -313,10 +320,11 @@ class QrcodeService:
|
||||
if user_id in DEV_USERS:
|
||||
return DEV_USERS[user_id]
|
||||
|
||||
# 兜底:用 settings 默认 dev 用户
|
||||
# 兜底:用 settings 默认 dev 用户(dev 模式无企微头像,avatar 留空)
|
||||
return (
|
||||
settings.dev_default_userid,
|
||||
settings.dev_default_name,
|
||||
"",
|
||||
)
|
||||
|
||||
async def _fetch_oauth_user(self, code: str) -> tuple[str, str]:
|
||||
@@ -350,7 +358,8 @@ class QrcodeService:
|
||||
|
||||
user_info = await wecom.get_user_info(user_id)
|
||||
name = user_info.get("name", "")
|
||||
return user_id, name
|
||||
avatar = user_info.get("avatar", "")
|
||||
return user_id, name, avatar
|
||||
finally:
|
||||
try:
|
||||
await wecom.close()
|
||||
@@ -408,6 +417,7 @@ class QrcodeService:
|
||||
|
||||
employee_id = scan_data.get("employee_id", "")
|
||||
name = scan_data.get("name", "")
|
||||
avatar = scan_data.get("avatar", "")
|
||||
if not employee_id:
|
||||
raise ValueError("扫码数据缺少 employee_id")
|
||||
|
||||
@@ -432,6 +442,7 @@ class QrcodeService:
|
||||
employee_id=employee_id,
|
||||
name=name,
|
||||
roles=roles,
|
||||
avatar=avatar,
|
||||
login_source="qrcode",
|
||||
)
|
||||
|
||||
@@ -458,6 +469,7 @@ class QrcodeService:
|
||||
"token": token,
|
||||
"employee_id": employee_id,
|
||||
"name": name,
|
||||
"avatar": avatar,
|
||||
"roles": roles,
|
||||
"require_otp": require_otp,
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.agent import Agent
|
||||
from app.models.conversation import Conversation
|
||||
from app.services.avatar_service import clean_avatar_url
|
||||
from app.services.wecom_service import WecomService
|
||||
from app.utils.response import (
|
||||
AppException,
|
||||
@@ -260,6 +261,66 @@ class SessionService:
|
||||
|
||||
return conversation
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 自动分配空闲坐席(排队系统核心)
|
||||
# --------------------------------------------------------------------------
|
||||
async def auto_assign_agent(
|
||||
self, conversation_id: UUID
|
||||
) -> Optional[Agent]:
|
||||
"""自动分配空闲坐席。
|
||||
|
||||
查找当前负载最低的空闲坐席进行分配。
|
||||
|
||||
流程:
|
||||
1. 查询所有状态为online且未满负荷的坐席
|
||||
2. 按current_load升序排列(负载最低的优先)
|
||||
3. 分配给负载最低的坐席
|
||||
4. 更新会话状态为serving
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
|
||||
Returns:
|
||||
Agent: 分配成功的坐席对象;None表示无空闲坐席
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from app.models.agent import Agent
|
||||
|
||||
# 1. 查询空闲坐席(在线且未满负荷)
|
||||
stmt = select(Agent).where(
|
||||
Agent.status == "online",
|
||||
Agent.current_load < Agent.max_load
|
||||
).order_by(Agent.current_load.asc())
|
||||
|
||||
result = await self.db.execute(stmt)
|
||||
agents = result.scalars().all()
|
||||
|
||||
if not agents:
|
||||
logger.info(f"无空闲坐席: conv_id={conversation_id}")
|
||||
return None
|
||||
|
||||
# 2. 选择负载最低的坐席
|
||||
agent = agents[0]
|
||||
|
||||
# 3. 分配坐席
|
||||
conversation = await self._get_conversation(conversation_id)
|
||||
conversation.status = "serving"
|
||||
conversation.assigned_agent_id = agent.user_id
|
||||
conversation.updated_at = datetime.now()
|
||||
self.db.add(conversation)
|
||||
|
||||
# 4. 更新坐席负载
|
||||
agent.current_load += 1
|
||||
self.db.add(agent)
|
||||
|
||||
await self.db.flush()
|
||||
|
||||
logger.info(
|
||||
f"自动分配坐席: conv_id={conversation_id}, agent={agent.user_id}, load={agent.current_load}/{agent.max_load}"
|
||||
)
|
||||
|
||||
return agent
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 结单
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -822,8 +883,8 @@ class SessionService:
|
||||
# 邀请功能(P0-09~P0-11):坐席邀请员工/部门加入会话
|
||||
# ======================================================================
|
||||
|
||||
# 头像缓存 TTL:7天
|
||||
AVATAR_CACHE_TTL = 7 * 24 * 60 * 60
|
||||
# 头像缓存 TTL:1天(要求 B:原 7 天过长,长期缓存过期/失效 URL 导致前端裂图)
|
||||
AVATAR_CACHE_TTL = 1 * 24 * 60 * 60
|
||||
|
||||
async def _get_employee_avatar(self, employee_id: str) -> str:
|
||||
"""获取员工头像URL(带Redis缓存)。
|
||||
@@ -831,7 +892,11 @@ class SessionService:
|
||||
优先级:
|
||||
1. Redis 缓存(最快)
|
||||
2. employees 表
|
||||
3. 企微API(获取后存入Redis缓存)
|
||||
3. 企微API(获取后存入Redis缓存 + 回写 DB 稳定 URL)
|
||||
|
||||
头像 URL 稳定性处理(要求 B):
|
||||
- 缓存 TTL 由 7 天缩短为 1 天,避免长期缓存过期/失效 URL。
|
||||
- 返回前清理企微头像 URL 的多余查询参数,保留稳定部分,降低 404 概率。
|
||||
|
||||
Args:
|
||||
employee_id: 企微员工UserID
|
||||
@@ -846,14 +911,16 @@ class SessionService:
|
||||
try:
|
||||
cached_avatar = await self.redis_client.get(cache_key)
|
||||
if cached_avatar:
|
||||
logger.debug(f"从Redis缓存获取头像: employee_id={employee_id}")
|
||||
return cached_avatar.decode("utf-8") if isinstance(cached_avatar, bytes) else cached_avatar
|
||||
# 兼容历史缓存中可能带查询参数,统一清理后返回
|
||||
return clean_avatar_url(
|
||||
cached_avatar.decode("utf-8") if isinstance(cached_avatar, bytes) else cached_avatar
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"从Redis获取头像缓存失败: employee_id={employee_id}, error={e}")
|
||||
|
||||
# 2. 从 employees 表获取(需要匹配 corp_id)
|
||||
from app.models.employee import Employee
|
||||
from app.core.config import settings
|
||||
from app.config import settings
|
||||
result = await self.db.execute(
|
||||
select(Employee.avatar).where(
|
||||
Employee.employee_id == employee_id,
|
||||
@@ -862,14 +929,15 @@ class SessionService:
|
||||
)
|
||||
row = result.first()
|
||||
if row and row[0]:
|
||||
logger.info(f"从employees表获取头像: employee_id={employee_id}, avatar={row[0][:50]}...")
|
||||
# 存入 Redis 缓存
|
||||
cleaned = clean_avatar_url(row[0])
|
||||
logger.info(f"从employees表获取头像: employee_id={employee_id}, avatar={cleaned[:50]}...")
|
||||
# 存入 Redis 缓存(已清理的稳定 URL)
|
||||
if self.redis_client:
|
||||
try:
|
||||
await self.redis_client.setex(cache_key, self.AVATAR_CACHE_TTL, row[0])
|
||||
await self.redis_client.setex(cache_key, self.AVATAR_CACHE_TTL, cleaned)
|
||||
except Exception as e:
|
||||
logger.warning(f"存入Redis头像缓存失败: employee_id={employee_id}, error={e}")
|
||||
return row[0]
|
||||
return cleaned
|
||||
else:
|
||||
logger.info(f"employees表无头像记录: employee_id={employee_id}")
|
||||
|
||||
@@ -878,7 +946,8 @@ class SessionService:
|
||||
if self.wecom_service:
|
||||
try:
|
||||
user_info = await self.wecom_service.get_user_info(employee_id)
|
||||
avatar = user_info.get("avatar", "")
|
||||
raw = user_info.get("avatar", "")
|
||||
avatar = clean_avatar_url(raw)
|
||||
logger.info(f"企微API返回头像: employee_id={employee_id}, avatar={'有值(' + str(len(avatar)) + '字符)' if avatar else '空'}")
|
||||
# 存入 Redis 缓存(即使为空也缓存,避免频繁请求API)
|
||||
if self.redis_client and avatar:
|
||||
@@ -886,6 +955,21 @@ class SessionService:
|
||||
await self.redis_client.setex(cache_key, self.AVATAR_CACHE_TTL, avatar)
|
||||
except Exception as e:
|
||||
logger.warning(f"存入Redis头像缓存失败: employee_id={employee_id}, error={e}")
|
||||
# 回写 DB:把稳定 URL 落库,下次直接从 DB 读取,减少企微 API 调用
|
||||
if avatar:
|
||||
try:
|
||||
from sqlalchemy import update as sa_update
|
||||
await self.db.execute(
|
||||
sa_update(Employee)
|
||||
.where(
|
||||
Employee.employee_id == employee_id,
|
||||
Employee.corp_id == settings.wecom_corp_id,
|
||||
)
|
||||
.values(avatar=avatar, avatar_updated_at=datetime.utcnow())
|
||||
)
|
||||
await self.db.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"回写员工头像到DB失败: employee_id={employee_id}, error={e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"从企微API获取头像失败: employee_id={employee_id}, error={e}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user