feat: 2026-07-11 全量更新 - 代办集成+会议室预定+知识迭代修复+UI统一+Bug修复
== 已部署上线 (9项) == - 代办事项真实数据源集成 (企微审批API 8bug修复链) - H5/坐席端 Logo样式统一+绿色背景 - 视频引导页修复 (localStorage key v2) - 坐席端 v9 Vue版本修复 (ElMessage._context) - 截图按钮 v10 修复 (getDisplayMedia user gesture) - 扫码样式恢复+H5扫码登录跳转修复 - H5截图快捷键提示 == 代码完成待部署 (3项) == - 知识迭代3Bug修复 (#8 POST端点/#7 MERGE幂等/#6 过期检查) - 会议室预定-小鱼易联终端 (40文件, 40/40测试通过) - IT资产升级审批推送 (asset_service.py) == 需求文档 (2项) == - 坐席端AI辅助消息框-PRD (4项新功能确认) - 坐席端布局优化建议 v2.0 (7天计划) == 新增文档 == - 日报-2026-07-11.md - 知识迭代Bug修复报告-20260711.md - 会议室预定-部署指南.md - CHANGELOG.md 更新 == 测试 == - test_todo_integration.py: 40/40 - test_meetingroom.py: 40/40 - test_bugfix_ki_suggestions.py: 21/21
This commit is contained in:
@@ -1,10 +1,6 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 服务包初始化
|
||||
# 企微IT智能服务台 — 服务包初始化(精简版,适配容器环境)
|
||||
# =============================================================================
|
||||
# 说明:将 services/ 目录标记为 Python 包
|
||||
# 导出所有服务类,方便统一导入
|
||||
# =============================================================================
|
||||
|
||||
from app.services.wecom_service import WecomService
|
||||
from app.services.message_router import MessageRouter
|
||||
from app.services.scoring_service import ScoringService
|
||||
@@ -12,13 +8,6 @@ from app.services.session_service import SessionService
|
||||
from app.services.funny_phrase_service import FunnyPhraseService
|
||||
from app.services.ai_handler import AIHandler
|
||||
|
||||
# Tier0 新增服务导出
|
||||
from app.services.neo4j_client import Neo4jClient, dep_neo4j_client, get_neo4j_client
|
||||
from app.services.knowledge_iteration_service import KnowledgeIterationService, dep_knowledge_iteration_service
|
||||
from app.services.wingman_service import WingmanService
|
||||
from app.services.vision_service import VisionService
|
||||
from app.services.ragflow_ingestion_service import RagflowIngestionService
|
||||
|
||||
__all__ = [
|
||||
"WecomService",
|
||||
"MessageRouter",
|
||||
@@ -26,13 +15,4 @@ __all__ = [
|
||||
"SessionService",
|
||||
"FunnyPhraseService",
|
||||
"AIHandler",
|
||||
# Tier0
|
||||
"Neo4jClient",
|
||||
"dep_neo4j_client",
|
||||
"get_neo4j_client",
|
||||
"KnowledgeIterationService",
|
||||
"dep_knowledge_iteration_service",
|
||||
"WingmanService",
|
||||
"VisionService",
|
||||
"RagflowIngestionService",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
# =============================================================================
|
||||
# IT智能服务台 — 资产数据服务
|
||||
# =============================================================================
|
||||
# 说明:从 Excel 资产清单中读取资产信息,用于审批推送时的设备年限核查。
|
||||
# - 懒加载 Excel(首次查询时才加载文件)
|
||||
# - 按资产编号查询(遍历所有月度 sheet)
|
||||
# - 计算使用年限(今天 - 开始使用日期)
|
||||
# - 生成审批意见文本
|
||||
#
|
||||
# Excel 结构:
|
||||
# - 12 个月度 sheet(202501-202512)+ 1 个统计 sheet
|
||||
# - 前 3 行是组织架构汇总行(非资产数据),实际资产数据从第 6 行开始
|
||||
# - 区分资产行:固定资产编码列(第2列)非 None
|
||||
#
|
||||
# 后续可替换为 API 调用(保持 find_asset / check_device_age 接口不变即可)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AssetService:
|
||||
"""资产数据服务 — 前期从 Excel 读取,后续可替换为 API。
|
||||
|
||||
Attributes:
|
||||
excel_path: Excel 资产清单文件路径
|
||||
"""
|
||||
|
||||
# 固定资产编码列名
|
||||
ASSET_CODE_COLUMN = "固定资产编码"
|
||||
# 开始使用日期列名
|
||||
START_DATE_COLUMN = "开始使用日期"
|
||||
|
||||
# Excel 列映射(列名 → 列序,从1开始)
|
||||
COLUMN_MAP = {
|
||||
"组织架构": 1,
|
||||
"固定资产编码": 2,
|
||||
"原资产编码": 3,
|
||||
"固定资产名称": 4,
|
||||
"固定资产类别": 5,
|
||||
"实物入账日期": 9,
|
||||
"财务入账日期": 10,
|
||||
"使用状态": 13,
|
||||
"存放地点": 15,
|
||||
"使用部门": 16,
|
||||
"使用人": 17,
|
||||
"交付日期": 40,
|
||||
"开始使用日期": 41,
|
||||
"预计使用年限": 42,
|
||||
}
|
||||
|
||||
# 月度 sheet 名称前缀(匹配 202501-202512)
|
||||
MONTHLY_SHEET_PREFIX = "2025"
|
||||
# 资产数据起始行(前3行组织架构汇总 + 标题行 + 空行)
|
||||
DATA_START_ROW = 6
|
||||
|
||||
def __init__(self, excel_path: str = None):
|
||||
"""初始化,懒加载 Excel。
|
||||
|
||||
Args:
|
||||
excel_path: Excel 文件路径,默认从 settings.asset_excel_path 读取
|
||||
"""
|
||||
self.excel_path = excel_path or settings.asset_excel_path
|
||||
self._workbook = None
|
||||
self._loaded = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部方法
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _ensure_loaded(self):
|
||||
"""懒加载 Excel 文件 — 首次调用时才打开文件。"""
|
||||
if self._loaded:
|
||||
return
|
||||
try:
|
||||
self._workbook = load_workbook(
|
||||
self.excel_path,
|
||||
data_only=True, # 读取公式计算后的缓存值
|
||||
read_only=True, # 只读模式,减少内存占用
|
||||
)
|
||||
self._loaded = True
|
||||
logger.info(f"Excel 资产清单加载成功: {self.excel_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"加载 Excel 资产清单失败: {e}")
|
||||
raise
|
||||
|
||||
def _get_monthly_sheets(self) -> list[str]:
|
||||
"""获取所有月度 sheet 名称(202501-202512),按时间升序排列。"""
|
||||
self._ensure_loaded()
|
||||
sheets = []
|
||||
for name in self._workbook.sheetnames:
|
||||
if name.startswith(self.MONTHLY_SHEET_PREFIX) and len(name) == 6:
|
||||
sheets.append(name)
|
||||
return sorted(sheets)
|
||||
|
||||
def _build_asset_info(self, row: tuple, sheet_name: str) -> dict:
|
||||
"""从 Excel 行数据构建资产信息字典。
|
||||
|
||||
Args:
|
||||
row: openpyxl 行数据(values_only=True 的元组)
|
||||
sheet_name: 所属 sheet 名称
|
||||
|
||||
Returns:
|
||||
dict: 资产信息字典,key 为列名
|
||||
"""
|
||||
def safe_get(col_num: int):
|
||||
"""安全获取列值(列序从1开始,转为0-based索引)。"""
|
||||
idx = col_num - 1
|
||||
if idx < len(row):
|
||||
return row[idx]
|
||||
return None
|
||||
|
||||
info = {"sheet_name": sheet_name}
|
||||
for col_name, col_num in self.COLUMN_MAP.items():
|
||||
info[col_name] = safe_get(col_num)
|
||||
return info
|
||||
|
||||
def _parse_date(self, date_value) -> Optional[date]:
|
||||
"""解析日期值 — Excel 中的日期可能是 date/datetime/字符串/数字。
|
||||
|
||||
Args:
|
||||
date_value: Excel 单元格原始值
|
||||
|
||||
Returns:
|
||||
date 对象,解析失败返回 None
|
||||
"""
|
||||
if date_value is None:
|
||||
return None
|
||||
|
||||
# datetime 对象 → 取 date 部分
|
||||
if isinstance(date_value, datetime):
|
||||
return date_value.date()
|
||||
|
||||
# date 对象 → 直接返回
|
||||
if isinstance(date_value, date):
|
||||
return date_value
|
||||
|
||||
# 字符串 → 尝试多种格式解析
|
||||
if isinstance(date_value, str):
|
||||
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d", "%Y年%m月%d日"):
|
||||
try:
|
||||
return datetime.strptime(date_value.strip(), fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Excel 数字日期序列号 → 转为 date
|
||||
if isinstance(date_value, (int, float)):
|
||||
try:
|
||||
# Excel 日期序列号:1900-01-01 = 1,修正闰年 bug 后基准为 1899-12-30
|
||||
return date(1899, 12, 30) + timedelta(days=int(date_value))
|
||||
except (OverflowError, ValueError):
|
||||
pass
|
||||
|
||||
logger.warning(f"无法解析日期值: {date_value} (type={type(date_value).__name__})")
|
||||
return None
|
||||
|
||||
def _format_years(self, years: float) -> str:
|
||||
"""将年限(浮点数)格式化为 'X年Y个月' 展示文本。
|
||||
|
||||
Args:
|
||||
years: 年限(如 5.17)
|
||||
|
||||
Returns:
|
||||
str: 如 "5年2个月"
|
||||
"""
|
||||
total_months = int(years * 12)
|
||||
years_part = total_months // 12
|
||||
months_part = total_months % 12
|
||||
return f"{years_part}年{months_part}个月"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 公开接口
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def find_asset(self, asset_code: str) -> Optional[dict]:
|
||||
"""按资产编号查询资产信息,遍历所有月度 sheet。
|
||||
|
||||
从最新月份往前查找,优先返回最新数据。匹配规则:trim + 大小写不敏感。
|
||||
|
||||
Args:
|
||||
asset_code: 资产编号
|
||||
|
||||
Returns:
|
||||
dict: 资产信息字典,未找到返回 None
|
||||
"""
|
||||
if not asset_code:
|
||||
return None
|
||||
|
||||
# trim + 大小写不敏感
|
||||
target_code = asset_code.strip().lower()
|
||||
|
||||
try:
|
||||
self._ensure_loaded()
|
||||
monthly_sheets = self._get_monthly_sheets()
|
||||
|
||||
# 从最新月份往前查找(优先返回最新数据)
|
||||
for sheet_name in reversed(monthly_sheets):
|
||||
sheet = self._workbook[sheet_name]
|
||||
|
||||
# 从第6行开始遍历(前3行组织架构汇总 + 标题/空行)
|
||||
for row in sheet.iter_rows(min_row=self.DATA_START_ROW, values_only=True):
|
||||
if len(row) < 2:
|
||||
continue
|
||||
# 第2列是固定资产编码(列序2 → 索引1)
|
||||
cell_value = row[1]
|
||||
if cell_value is None:
|
||||
continue
|
||||
|
||||
code_str = str(cell_value).strip().lower()
|
||||
if code_str == target_code:
|
||||
logger.info(f"找到资产: code={asset_code}, sheet={sheet_name}")
|
||||
return self._build_asset_info(row, sheet_name)
|
||||
|
||||
logger.warning(f"未找到资产编号: {asset_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"查询资产信息失败: {e}")
|
||||
return None
|
||||
|
||||
def check_device_age(self, asset_code: str, threshold_years: int = 5) -> dict:
|
||||
"""核查设备使用年限,返回核查结果和审批意见。
|
||||
|
||||
流程:
|
||||
1. 查询资产信息
|
||||
2. 获取"开始使用日期"
|
||||
3. 计算使用年限:(今天 - start_date).days / 365.25
|
||||
4. 判断是否 ≥ 阈值
|
||||
5. 生成审批意见文本
|
||||
|
||||
Args:
|
||||
asset_code: 资产编号
|
||||
threshold_years: 更换年限阈值,默认 5 年
|
||||
|
||||
Returns:
|
||||
dict: 核查结果,包含 found/asset_name/start_date/years_used/
|
||||
meets_threshold/opinion 等字段
|
||||
"""
|
||||
# 1. 查询资产信息
|
||||
asset = self.find_asset(asset_code)
|
||||
if not asset:
|
||||
return {
|
||||
"found": False,
|
||||
"asset_code": asset_code,
|
||||
"opinion": f"未在资产清单中找到编号 {asset_code},请人工核查。",
|
||||
}
|
||||
|
||||
# 2. 获取开始使用日期
|
||||
start_date_raw = asset.get(self.START_DATE_COLUMN)
|
||||
start_date = self._parse_date(start_date_raw)
|
||||
|
||||
asset_name = asset.get("固定资产名称") or "未知设备"
|
||||
|
||||
if not start_date:
|
||||
return {
|
||||
"found": True,
|
||||
"asset_code": asset_code,
|
||||
"asset_name": asset_name,
|
||||
"start_date": None,
|
||||
"years_used": None,
|
||||
"years_display": None,
|
||||
"meets_threshold": False,
|
||||
"opinion": f"资产编号 {asset_code}({asset_name})无开始使用日期记录,请人工核查。",
|
||||
}
|
||||
|
||||
# 3. 计算使用年限
|
||||
today = date.today()
|
||||
days_used = (today - start_date).days
|
||||
years_used = days_used / 365.25
|
||||
meets = years_used >= threshold_years
|
||||
|
||||
# 4. 格式化使用年限展示
|
||||
years_display = self._format_years(years_used)
|
||||
|
||||
# 5. 生成审批意见
|
||||
opinion = self._format_opinion(
|
||||
asset_name=asset_name,
|
||||
start_date=start_date,
|
||||
years_used=years_used,
|
||||
threshold=threshold_years,
|
||||
meets=meets,
|
||||
)
|
||||
|
||||
return {
|
||||
"found": True,
|
||||
"asset_code": asset_code,
|
||||
"asset_name": asset_name,
|
||||
"start_date": start_date.strftime("%Y-%m-%d"),
|
||||
"years_used": round(years_used, 2),
|
||||
"years_display": years_display,
|
||||
"meets_threshold": meets,
|
||||
"opinion": opinion,
|
||||
"asset_info": asset,
|
||||
}
|
||||
|
||||
def _format_opinion(
|
||||
self,
|
||||
asset_name: str,
|
||||
start_date: date,
|
||||
years_used: float,
|
||||
threshold: int,
|
||||
meets: bool,
|
||||
) -> str:
|
||||
"""生成审批意见文本。
|
||||
|
||||
Args:
|
||||
asset_name: 设备名称
|
||||
start_date: 开始使用日期
|
||||
years_used: 使用年限(浮点数)
|
||||
threshold: 更换阈值(年)
|
||||
meets: 是否满足更换条件
|
||||
|
||||
Returns:
|
||||
str: 审批意见文本
|
||||
"""
|
||||
years_display = self._format_years(years_used)
|
||||
|
||||
if meets:
|
||||
conclusion = f"✅ 已满{threshold}年,符合更换条件"
|
||||
else:
|
||||
conclusion = f"❌ 未满{threshold}年,不符合更换条件"
|
||||
|
||||
opinion = (
|
||||
f"设备名称:{asset_name}\n"
|
||||
f"启用日期:{start_date.strftime('%Y-%m-%d')}\n"
|
||||
f"已使用:{years_display}\n"
|
||||
f"核查结论:{conclusion}"
|
||||
)
|
||||
return opinion
|
||||
|
||||
def close(self):
|
||||
"""关闭 Excel 文件句柄,释放资源。"""
|
||||
if self._workbook:
|
||||
try:
|
||||
self._workbook.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._workbook = None
|
||||
self._loaded = False
|
||||
@@ -0,0 +1,296 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 统一认证服务
|
||||
# =============================================================================
|
||||
# 说明:统一认证核心逻辑,支持:
|
||||
# 1. Token 创建、验证、刷新、失效
|
||||
# 2. 登录日志记录
|
||||
# 3. 角色切换
|
||||
# 4. 登出功能(Token 黑名单)
|
||||
# 三端认证重构:取消OTP/账号密码登录,统一为企微扫码认证
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.login_log import LoginLog
|
||||
from app.services.token_service import TokenService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Token TTL(8小时)
|
||||
TOKEN_TTL_SECONDS = 8 * 60 * 60
|
||||
|
||||
# Token 黑名单 Key 前缀
|
||||
TOKEN_BLACKLIST_PREFIX = "token:blacklist:"
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""统一认证服务。
|
||||
|
||||
管理用户认证的完整流程,包括 Token 管理和登录日志。
|
||||
"""
|
||||
|
||||
def __init__(self, redis_client: aioredis.Redis, db: Optional[AsyncSession] = None):
|
||||
"""初始化认证服务。
|
||||
|
||||
Args:
|
||||
redis_client: Redis 异步客户端
|
||||
db: 数据库会话(可选,用于记录登录日志)
|
||||
"""
|
||||
self.redis = redis_client
|
||||
self.db = db
|
||||
self.token_service = TokenService(redis_client)
|
||||
|
||||
async def create_token_and_login(
|
||||
self,
|
||||
employee_id: str,
|
||||
name: str,
|
||||
roles: List[str],
|
||||
corp_id: str,
|
||||
login_method: str,
|
||||
login_source: str,
|
||||
department: Optional[str] = None,
|
||||
avatar: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
) -> Dict:
|
||||
"""创建 Token 并记录登录日志。
|
||||
|
||||
Args:
|
||||
employee_id: 企微 UserID
|
||||
name: 用户姓名
|
||||
roles: 角色列表
|
||||
corp_id: 企业ID
|
||||
login_method: 登录方式 (oauth/qrcode/bind)
|
||||
login_source: 登录来源 (h5/agent/admin)
|
||||
department: 部门(可选)
|
||||
avatar: 头像URL(可选)
|
||||
ip_address: 客户端IP(可选)
|
||||
user_agent: 客户端User-Agent(可选)
|
||||
|
||||
Returns:
|
||||
Dict: 包含 token 和用户信息的字典
|
||||
"""
|
||||
# 创建 Token
|
||||
token = await self.token_service.create_token(
|
||||
employee_id=employee_id,
|
||||
name=name,
|
||||
roles=roles,
|
||||
department=department,
|
||||
avatar=avatar,
|
||||
login_source=login_source,
|
||||
)
|
||||
|
||||
# 记录登录日志
|
||||
await self._record_login_log(
|
||||
employee_id=employee_id,
|
||||
corp_id=corp_id,
|
||||
login_method=login_method,
|
||||
login_source=login_source,
|
||||
status="success",
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
# 获取当前角色
|
||||
current_role = self.token_service._get_default_role(roles)
|
||||
|
||||
logger.info(
|
||||
f"用户登录成功: employee_id={employee_id}, "
|
||||
f"login_method={login_method}, login_source={login_source}"
|
||||
)
|
||||
|
||||
return {
|
||||
"token": token,
|
||||
"employee_id": employee_id,
|
||||
"name": name,
|
||||
"avatar": avatar or "",
|
||||
"department": department or "",
|
||||
"roles": roles,
|
||||
"current_role": current_role,
|
||||
"login_source": login_source,
|
||||
"expires_in": TOKEN_TTL_SECONDS,
|
||||
}
|
||||
|
||||
async def verify_token(self, token: str) -> Optional[Dict]:
|
||||
"""验证 Token 并返回用户信息。
|
||||
|
||||
Args:
|
||||
token: Token 字符串
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: 用户信息,如果 Token 无效返回 None
|
||||
"""
|
||||
# 检查是否在黑名单中
|
||||
is_blacklisted = await self._is_token_blacklisted(token)
|
||||
if is_blacklisted:
|
||||
logger.warning(f"Token 在黑名单中: {token[:10]}...")
|
||||
return None
|
||||
|
||||
# 验证 Token
|
||||
user_info = await self.token_service.get_user_info(token)
|
||||
return user_info
|
||||
|
||||
async def logout(self, token: str) -> bool:
|
||||
"""登出:将 Token 加入黑名单。
|
||||
|
||||
Args:
|
||||
token: 要失效的 Token 字符串
|
||||
|
||||
Returns:
|
||||
bool: 是否成功
|
||||
"""
|
||||
# 将 Token 加入黑名单
|
||||
await self._add_to_blacklist(token)
|
||||
|
||||
# 使 Redis 中的 Token 失效
|
||||
await self.token_service.invalidate_token(token)
|
||||
|
||||
logger.info(f"用户登出: token={token[:10]}...")
|
||||
return True
|
||||
|
||||
async def switch_role(self, token: str, new_role: str) -> bool:
|
||||
"""切换当前角色。
|
||||
|
||||
Args:
|
||||
token: Token 字符串
|
||||
new_role: 目标角色标识
|
||||
|
||||
Returns:
|
||||
bool: 是否切换成功
|
||||
"""
|
||||
return await self.token_service.switch_role(token, new_role)
|
||||
|
||||
async def get_current_user(self, token: str) -> Optional[Dict]:
|
||||
"""获取当前用户信息。
|
||||
|
||||
Args:
|
||||
token: Token 字符串
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: 用户信息
|
||||
"""
|
||||
return await self.verify_token(token)
|
||||
|
||||
async def record_failed_login(
|
||||
self,
|
||||
corp_id: str,
|
||||
login_method: str,
|
||||
login_source: str,
|
||||
fail_reason: str,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
) -> None:
|
||||
"""记录失败的登录尝试。
|
||||
|
||||
Args:
|
||||
corp_id: 企业ID
|
||||
login_method: 登录方式
|
||||
login_source: 登录来源
|
||||
fail_reason: 失败原因
|
||||
ip_address: 客户端IP(可选)
|
||||
user_agent: 客户端User-Agent(可选)
|
||||
"""
|
||||
await self._record_login_log(
|
||||
employee_id=None,
|
||||
corp_id=corp_id,
|
||||
login_method=login_method,
|
||||
login_source=login_source,
|
||||
status="failed",
|
||||
fail_reason=fail_reason,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
f"登录失败: login_method={login_method}, "
|
||||
f"login_source={login_source}, reason={fail_reason}"
|
||||
)
|
||||
|
||||
async def _record_login_log(
|
||||
self,
|
||||
employee_id: Optional[str],
|
||||
corp_id: str,
|
||||
login_method: str,
|
||||
login_source: str,
|
||||
status: str,
|
||||
fail_reason: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
) -> None:
|
||||
"""记录登录日志到数据库。
|
||||
|
||||
Args:
|
||||
employee_id: 员工ID
|
||||
corp_id: 企业ID
|
||||
login_method: 登录方式
|
||||
login_source: 登录来源
|
||||
status: 登录状态
|
||||
fail_reason: 失败原因
|
||||
ip_address: 客户端IP
|
||||
user_agent: 客户端User-Agent
|
||||
"""
|
||||
if not self.db:
|
||||
logger.debug("未配置数据库会话,跳过登录日志记录")
|
||||
return
|
||||
|
||||
try:
|
||||
login_log = LoginLog(
|
||||
employee_id=employee_id,
|
||||
corp_id=corp_id,
|
||||
login_method=login_method,
|
||||
login_source=login_source,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
status=status,
|
||||
fail_reason=fail_reason,
|
||||
)
|
||||
self.db.add(login_log)
|
||||
await self.db.commit()
|
||||
logger.debug(f"登录日志已记录: employee_id={employee_id}, status={status}")
|
||||
except Exception as e:
|
||||
logger.error(f"记录登录日志失败: {e}")
|
||||
await self.db.rollback()
|
||||
|
||||
async def _add_to_blacklist(self, token: str) -> None:
|
||||
"""将 Token 加入黑名单。
|
||||
|
||||
Args:
|
||||
token: Token 字符串
|
||||
"""
|
||||
try:
|
||||
# 使用 Token 的 hash 作为 key,避免存储明文 Token
|
||||
import hashlib
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
key = f"{TOKEN_BLACKLIST_PREFIX}{token_hash}"
|
||||
|
||||
# 设置黑名单过期时间为 Token 原始过期时间
|
||||
await self.redis.setex(key, TOKEN_TTL_SECONDS, "1")
|
||||
logger.debug(f"Token 已加入黑名单: {token[:10]}...")
|
||||
except Exception as e:
|
||||
logger.error(f"添加 Token 到黑名单失败: {e}")
|
||||
|
||||
async def _is_token_blacklisted(self, token: str) -> bool:
|
||||
"""检查 Token 是否在黑名单中。
|
||||
|
||||
Args:
|
||||
token: Token 字符串
|
||||
|
||||
Returns:
|
||||
bool: 是否在黑名单中
|
||||
"""
|
||||
try:
|
||||
import hashlib
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
key = f"{TOKEN_BLACKLIST_PREFIX}{token_hash}"
|
||||
|
||||
result = await self.redis.get(key)
|
||||
return result is not None
|
||||
except Exception as e:
|
||||
logger.error(f"检查 Token 黑名单失败: {e}")
|
||||
return False
|
||||
@@ -125,14 +125,20 @@ from app.services.automation.exception_handler import ( # noqa: E402
|
||||
to_app_exception,
|
||||
)
|
||||
from app.services.automation.executor import ActionExecutor # noqa: E402
|
||||
from app.services.automation.information_item_service import InformationItemService # 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_info_corrected,
|
||||
publish_info_supplemented,
|
||||
publish_paused,
|
||||
publish_progress,
|
||||
publish_resolved,
|
||||
publish_resumed,
|
||||
publish_takeover,
|
||||
publish_timeout_closed,
|
||||
register_ws,
|
||||
unregister_ws,
|
||||
)
|
||||
@@ -141,6 +147,10 @@ from app.services.automation.session_manager import ( # noqa: E402
|
||||
AutoSessionService,
|
||||
run_session_in_background,
|
||||
)
|
||||
from app.services.automation.context_compressor import ContextCompressor # noqa: E402
|
||||
from app.services.automation.correction_service import CorrectionService # noqa: E402
|
||||
from app.services.automation.snapshot_service import SnapshotService # noqa: E402
|
||||
from app.services.automation.timeout_cleaner import TimeoutCleaner # noqa: E402
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_SCENARIO_CONFIGS",
|
||||
@@ -153,16 +163,26 @@ __all__ = [
|
||||
"AutomationException",
|
||||
"to_app_exception",
|
||||
"ActionExecutor",
|
||||
"InformationItemService",
|
||||
"IntentRouter",
|
||||
"MappingResolver",
|
||||
"publish_action_required",
|
||||
"publish_error",
|
||||
"publish_info_corrected",
|
||||
"publish_info_supplemented",
|
||||
"publish_paused",
|
||||
"publish_progress",
|
||||
"publish_resolved",
|
||||
"publish_resumed",
|
||||
"publish_takeover",
|
||||
"publish_timeout_closed",
|
||||
"register_ws",
|
||||
"unregister_ws",
|
||||
"RollbackService",
|
||||
"AutoSessionService",
|
||||
"run_session_in_background",
|
||||
"TimeoutCleaner",
|
||||
"ContextCompressor",
|
||||
"CorrectionService",
|
||||
"SnapshotService",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
上下文压缩引擎 — P2 核心组件。
|
||||
|
||||
当会话上下文超过 token 阈值时,自动压缩历史对话:
|
||||
1. 提取关键信息(信息项当前值、已执行动作、任务节点)
|
||||
2. 调用 LLM 对非关键历史生成摘要
|
||||
3. 组装压缩后上下文(结构化 Markdown)
|
||||
4. 渐进式压缩:单次不够则二次,最多3级,超出降级截断
|
||||
5. 记录压缩日志到 auto_context_compressions 表
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.automation import AutoAction, AutoSession, ContextCompression, InformationItem
|
||||
from app.utils.token_counter import TokenCounter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 压缩摘要 LLM prompt
|
||||
_SUMMARY_SYSTEM_PROMPT = (
|
||||
"你是对话摘要助手。请将以下IT服务台对话历史压缩为简洁摘要,"
|
||||
"保留:1)员工诉求 2)已收集的关键信息 3)已执行的排查步骤 4)决策点。"
|
||||
"输出不超过300字的中文摘要。"
|
||||
)
|
||||
|
||||
# 压缩后上下文模板
|
||||
_COMPRESSED_CONTEXT_TEMPLATE = """## 会话上下文摘要(系统压缩)
|
||||
|
||||
### 已收集信息项
|
||||
{info_items}
|
||||
|
||||
### 已执行动作
|
||||
{actions}
|
||||
|
||||
### 当前任务节点
|
||||
{task_node}
|
||||
|
||||
### 历史摘要
|
||||
{summary}
|
||||
|
||||
### 最近对话
|
||||
{recent_messages}
|
||||
"""
|
||||
|
||||
|
||||
class ContextCompressor:
|
||||
"""上下文压缩引擎。
|
||||
|
||||
在每次调用 LLM 前检查 token 数,超阈值时执行压缩。
|
||||
"""
|
||||
|
||||
def __init__(self, db: AsyncSession, dify_client: Any = None):
|
||||
self.db = db
|
||||
self.dify_client = dify_client
|
||||
# 从配置读取,带默认值兜底(pydantic-settings 属性名为小写)
|
||||
self.threshold = getattr(settings, "context_compress_threshold", 6000)
|
||||
self.timeout = getattr(settings, "context_compress_timeout", 30)
|
||||
self.max_level = getattr(settings, "context_max_compress_level", 3)
|
||||
self.keep_recent = getattr(settings, "context_keep_recent_turns", 4)
|
||||
|
||||
def count_tokens(self, messages: List[dict]) -> int:
|
||||
"""计算消息列表的 token 总数。"""
|
||||
return TokenCounter.count_messages_tokens(messages)
|
||||
|
||||
def should_compress(self, messages: List[dict]) -> bool:
|
||||
"""判断是否需要压缩。"""
|
||||
token_count = self.count_tokens(messages)
|
||||
return token_count > self.threshold
|
||||
|
||||
async def compress(
|
||||
self,
|
||||
session_id: str,
|
||||
messages: List[dict],
|
||||
info_items: List[InformationItem],
|
||||
actions: List[AutoAction],
|
||||
task_node: str = "",
|
||||
) -> dict:
|
||||
"""执行上下文压缩。
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"compressed_messages": List[dict], # 压缩后的消息列表
|
||||
"tokens_before": int,
|
||||
"tokens_after": int,
|
||||
"compression_ratio": float,
|
||||
"compression_level": int,
|
||||
"summary": str,
|
||||
"duration_ms": int,
|
||||
}
|
||||
"""
|
||||
start_time = time.time()
|
||||
tokens_before = self.count_tokens(messages)
|
||||
|
||||
# Level 1 压缩
|
||||
compressed = await self._compress_level1(
|
||||
messages, info_items, actions, task_node
|
||||
)
|
||||
|
||||
compression_level = 1
|
||||
tokens_after = self.count_tokens(compressed)
|
||||
|
||||
# 渐进式压缩
|
||||
while tokens_after > self.threshold and compression_level < self.max_level:
|
||||
compression_level += 1
|
||||
keep = max(2, self.keep_recent - compression_level + 1) # 逐级减少保留轮数
|
||||
compressed = await self._compress_level_n(
|
||||
messages, info_items, actions, task_node, keep
|
||||
)
|
||||
tokens_after = self.count_tokens(compressed)
|
||||
|
||||
# 超过最大级别仍超限 → 截断降级
|
||||
if tokens_after > self.threshold:
|
||||
compressed = self._truncate_messages(
|
||||
compressed, info_items, actions, task_node
|
||||
)
|
||||
tokens_after = self.count_tokens(compressed)
|
||||
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
compression_ratio = round(tokens_after / max(1, tokens_before), 2)
|
||||
|
||||
# 写入压缩日志
|
||||
await self._write_log(
|
||||
session_id=session_id,
|
||||
tokens_before=tokens_before,
|
||||
tokens_after=tokens_after,
|
||||
compression_ratio=compression_ratio,
|
||||
task_node=task_node,
|
||||
duration_ms=duration_ms,
|
||||
compression_level=compression_level,
|
||||
summary=compressed[0].get("content", "")[:500] if compressed else "",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"上下文压缩完成: session={session_id} "
|
||||
f"{tokens_before}->{tokens_after} (ratio={compression_ratio}, "
|
||||
f"level={compression_level}, {duration_ms}ms)"
|
||||
)
|
||||
|
||||
return {
|
||||
"compressed_messages": compressed,
|
||||
"tokens_before": tokens_before,
|
||||
"tokens_after": tokens_after,
|
||||
"compression_ratio": compression_ratio,
|
||||
"compression_level": compression_level,
|
||||
"summary": compressed[0].get("content", "")[:500] if compressed else "",
|
||||
"duration_ms": duration_ms,
|
||||
}
|
||||
|
||||
async def _compress_level1(
|
||||
self,
|
||||
messages: List[dict],
|
||||
info_items: List[InformationItem],
|
||||
actions: List[AutoAction],
|
||||
task_node: str,
|
||||
) -> List[dict]:
|
||||
"""Level 1 压缩:LLM 摘要 + 保留最近4轮对话。"""
|
||||
# 分割消息:保留最近 N 轮,其余用于摘要
|
||||
recent_msgs = self._get_recent_messages(messages, self.keep_recent)
|
||||
old_msgs = messages[: len(messages) - len(recent_msgs)]
|
||||
|
||||
# 提取关键信息
|
||||
key_info = self._extract_key_info(info_items, actions, task_node)
|
||||
|
||||
# 调用 LLM 生成摘要
|
||||
summary = await self._summarize_history(old_msgs)
|
||||
|
||||
# 组装压缩后上下文
|
||||
context_text = _COMPRESSED_CONTEXT_TEMPLATE.format(
|
||||
info_items=key_info["info_items"],
|
||||
actions=key_info["actions"],
|
||||
task_node=task_node or "未指定",
|
||||
summary=summary,
|
||||
recent_messages=self._format_recent_messages(recent_msgs),
|
||||
)
|
||||
|
||||
# 返回压缩后的消息列表(1条系统消息 + 最近对话)
|
||||
return [{"role": "system", "content": context_text}] + recent_msgs
|
||||
|
||||
async def _compress_level_n(
|
||||
self,
|
||||
messages: List[dict],
|
||||
info_items: List[InformationItem],
|
||||
actions: List[AutoAction],
|
||||
task_node: str,
|
||||
keep_turns: int,
|
||||
) -> List[dict]:
|
||||
"""Level N 压缩:减少保留轮数。"""
|
||||
recent_msgs = self._get_recent_messages(messages, keep_turns)
|
||||
key_info = self._extract_key_info(info_items, actions, task_node)
|
||||
|
||||
context_text = _COMPRESSED_CONTEXT_TEMPLATE.format(
|
||||
info_items=key_info["info_items"],
|
||||
actions=key_info["actions"],
|
||||
task_node=task_node or "未指定",
|
||||
summary="(已多次压缩,仅保留关键信息)",
|
||||
recent_messages=self._format_recent_messages(recent_msgs),
|
||||
)
|
||||
|
||||
return [{"role": "system", "content": context_text}] + recent_msgs
|
||||
|
||||
def _truncate_messages(
|
||||
self,
|
||||
messages: List[dict],
|
||||
info_items: List[InformationItem],
|
||||
actions: List[AutoAction],
|
||||
task_node: str,
|
||||
) -> List[dict]:
|
||||
"""降级截断:只保留关键信息 + 最近1轮对话。"""
|
||||
key_info = self._extract_key_info(info_items, actions, task_node)
|
||||
recent = self._get_recent_messages(messages, 1)
|
||||
|
||||
context_text = _COMPRESSED_CONTEXT_TEMPLATE.format(
|
||||
info_items=key_info["info_items"],
|
||||
actions=key_info["actions"],
|
||||
task_node=task_node or "未指定",
|
||||
summary="(降级截断:原始对话过长,仅保留关键信息)",
|
||||
recent_messages=self._format_recent_messages(recent),
|
||||
)
|
||||
|
||||
return [{"role": "system", "content": context_text}] + recent
|
||||
|
||||
def _extract_key_info(
|
||||
self,
|
||||
info_items: List[InformationItem],
|
||||
actions: List[AutoAction],
|
||||
task_node: str,
|
||||
) -> dict:
|
||||
"""提取关键信息(信息项当前值、已执行动作、任务节点)。"""
|
||||
# 信息项
|
||||
items_str = "\n".join(
|
||||
f"- {item.name}: {item.value}(v{item.version})"
|
||||
for item in info_items
|
||||
if item.is_filled
|
||||
) or "- (暂无已收集信息项)"
|
||||
|
||||
# 已执行动作
|
||||
actions_str = "\n".join(
|
||||
f"- {'✅' if a.status == 'success' else '⏳'} {a.title}({a.action_type})"
|
||||
for a in actions
|
||||
) or "- (暂无已执行动作)"
|
||||
|
||||
return {"info_items": items_str, "actions": actions_str}
|
||||
|
||||
async def _summarize_history(self, messages: List[dict]) -> str:
|
||||
"""调用 LLM 对历史消息生成摘要。"""
|
||||
if not messages:
|
||||
return "(无历史对话需摘要)"
|
||||
|
||||
# 拼接历史消息文本
|
||||
history_text = "\n".join(
|
||||
f"[{m.get('role', 'unknown')}] {m.get('content', '')}"
|
||||
for m in messages
|
||||
)
|
||||
|
||||
if self.dify_client is None:
|
||||
# 无 LLM 客户端 → 简单截取前500字作为摘要
|
||||
logger.warning("无 DifyClient,降级为截取前500字摘要")
|
||||
return history_text[:500] + "..." if len(history_text) > 500 else history_text
|
||||
|
||||
try:
|
||||
# 调用 Dify 做摘要
|
||||
import asyncio
|
||||
result = await asyncio.wait_for(
|
||||
self._call_llm_summary(history_text),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"LLM 摘要超时({self.timeout}s),降级为截断")
|
||||
return history_text[:500] + "...(摘要超时截断)"
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM 摘要失败: {e},降级为截断")
|
||||
return history_text[:500] + "...(摘要失败截断)"
|
||||
|
||||
async def _call_llm_summary(self, text: str) -> str:
|
||||
"""调用 LLM 生成摘要(复用 DifyClient)。"""
|
||||
# 构建摘要请求
|
||||
messages = [
|
||||
{"role": "system", "content": _SUMMARY_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": f"请摘要以下对话历史:\n\n{text}"},
|
||||
]
|
||||
# 调用 DifyClient 的 chat 方法
|
||||
if hasattr(self.dify_client, "chat_completion"):
|
||||
result = await self.dify_client.chat_completion(messages)
|
||||
return result.get("content", "")
|
||||
elif hasattr(self.dify_client, "chat"):
|
||||
result = await self.dify_client.chat(messages)
|
||||
return result.get("answer", result.get("content", ""))
|
||||
else:
|
||||
return text[:500]
|
||||
|
||||
def _get_recent_messages(self, messages: List[dict], turns: int) -> List[dict]:
|
||||
"""获取最近 N 轮对话(1轮 = 1条user + 1条assistant)。"""
|
||||
# 一轮 = 2条消息,取最近 turns*2 条
|
||||
take = min(len(messages), turns * 2)
|
||||
return messages[-take:] if take > 0 else []
|
||||
|
||||
def _format_recent_messages(self, messages: List[dict]) -> str:
|
||||
"""格式化最近对话为文本。"""
|
||||
if not messages:
|
||||
return "(无最近对话)"
|
||||
return "\n".join(
|
||||
f"[{m.get('role', 'unknown')}] {m.get('content', '')[:200]}"
|
||||
for m in messages
|
||||
)
|
||||
|
||||
async def _write_log(
|
||||
self,
|
||||
session_id: str,
|
||||
tokens_before: int,
|
||||
tokens_after: int,
|
||||
compression_ratio: float,
|
||||
task_node: str,
|
||||
duration_ms: int,
|
||||
compression_level: int,
|
||||
summary: str,
|
||||
) -> None:
|
||||
"""写入压缩日志到数据库。"""
|
||||
try:
|
||||
log = ContextCompression(
|
||||
session_id=session_id,
|
||||
tokens_before=tokens_before,
|
||||
tokens_after=tokens_after,
|
||||
compression_ratio=compression_ratio,
|
||||
task_node=task_node,
|
||||
duration_ms=duration_ms,
|
||||
compression_level=compression_level,
|
||||
summary=summary,
|
||||
)
|
||||
self.db.add(log)
|
||||
await self.db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"写入压缩日志失败: {e}")
|
||||
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
纠错服务 — P3 核心组件。
|
||||
|
||||
支持多轮纠错:批量更正、依赖检查、版本链查询、更正撤销。
|
||||
批量更正为单事务原子操作,失败整体回滚。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.automation import InformationItem, InformationSnapshot
|
||||
from app.services.automation.exception_handler import AutomationException
|
||||
from app.services.automation.snapshot_service import SnapshotService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BatchCorrectResult:
|
||||
"""批量更正结果。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
corrected_items: List[InformationItem],
|
||||
snapshot_id: int,
|
||||
dependency_warnings: List[dict],
|
||||
):
|
||||
self.corrected_items = corrected_items
|
||||
self.snapshot_id = snapshot_id
|
||||
self.dependency_warnings = dependency_warnings
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"corrected_items": [
|
||||
{
|
||||
"name": item.name,
|
||||
"value": item.value,
|
||||
"version": item.version,
|
||||
}
|
||||
for item in self.corrected_items
|
||||
],
|
||||
"snapshot_id": self.snapshot_id,
|
||||
"dependency_warnings": self.dependency_warnings,
|
||||
}
|
||||
|
||||
|
||||
class CorrectionService:
|
||||
"""多轮纠错服务。"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.snapshot_service = SnapshotService(db)
|
||||
|
||||
async def batch_correct(
|
||||
self,
|
||||
session_id: str,
|
||||
corrections: List[dict],
|
||||
reason: Optional[str] = None,
|
||||
) -> BatchCorrectResult:
|
||||
"""批量更正信息项(单事务原子操作)。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
corrections: 更正列表 [{field, new_value, old_value?}, ...]
|
||||
reason: 更正备注
|
||||
Returns:
|
||||
BatchCorrectResult: 更正结果
|
||||
Raises:
|
||||
AutomationException: 任何一项更正失败则整体回滚
|
||||
"""
|
||||
if not corrections:
|
||||
raise AutomationException(4009, "更正列表不能为空")
|
||||
|
||||
corrected_items = []
|
||||
correction_ids = []
|
||||
first_trigger_key = corrections[0].get("field", "")
|
||||
|
||||
try:
|
||||
# 1. 更正前创建快照
|
||||
snapshot = await self.snapshot_service.create_snapshot(
|
||||
session_id=session_id,
|
||||
trigger_item_key=first_trigger_key,
|
||||
correction_ids=correction_ids,
|
||||
)
|
||||
|
||||
# 2. 逐个更正
|
||||
for corr in corrections:
|
||||
field = corr["field"]
|
||||
new_value = corr["new_value"]
|
||||
old_value = corr.get("old_value")
|
||||
|
||||
item = await self._correct_single(
|
||||
session_id, field, new_value, old_value, reason
|
||||
)
|
||||
corrected_items.append(item)
|
||||
correction_ids.append(item.id)
|
||||
|
||||
# 3. 更新快照的 correction_ids
|
||||
snapshot.correction_ids = correction_ids
|
||||
await self.db.flush()
|
||||
|
||||
# 4. 检查依赖
|
||||
dependency_warnings = []
|
||||
for corr in corrections:
|
||||
warnings = await self.check_dependencies(
|
||||
session_id, corr["field"]
|
||||
)
|
||||
dependency_warnings.extend(warnings)
|
||||
|
||||
logger.info(
|
||||
f"批量更正完成: session={session_id} count={len(corrected_items)} "
|
||||
f"snapshot={snapshot.id}"
|
||||
)
|
||||
|
||||
return BatchCorrectResult(
|
||||
corrected_items=corrected_items,
|
||||
snapshot_id=snapshot.id,
|
||||
dependency_warnings=dependency_warnings,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# 事务回滚 — 整体失败
|
||||
logger.error(f"批量更正失败,回滚: session={session_id} error={e}")
|
||||
raise
|
||||
|
||||
async def _correct_single(
|
||||
self,
|
||||
session_id: str,
|
||||
name: str,
|
||||
new_value: str,
|
||||
old_value: Optional[str] = None,
|
||||
reason: Optional[str] = None,
|
||||
) -> InformationItem:
|
||||
"""更正单个信息项。"""
|
||||
item = await self._get_item(session_id, name)
|
||||
|
||||
if item is None:
|
||||
# 不存在则创建
|
||||
item = InformationItem(
|
||||
session_id=session_id,
|
||||
name=name,
|
||||
value=new_value,
|
||||
is_filled=True,
|
||||
version=1,
|
||||
update_history=[],
|
||||
derived_from=None,
|
||||
correction_reason=reason,
|
||||
)
|
||||
self.db.add(item)
|
||||
await self.db.flush()
|
||||
return item
|
||||
|
||||
if item.is_locked:
|
||||
raise AutomationException(
|
||||
4012, f"信息项「{name}」已锁定,不可更正"
|
||||
)
|
||||
|
||||
# 记录变更历史
|
||||
actual_old = old_value if old_value is not None else item.value
|
||||
history_entry = {
|
||||
"version": item.version,
|
||||
"old_value": actual_old,
|
||||
"new_value": new_value,
|
||||
"action": "correct",
|
||||
"reason": reason,
|
||||
"timestamp": __import__("datetime").datetime.now(
|
||||
__import__("datetime").timezone.utc
|
||||
).isoformat(),
|
||||
}
|
||||
history_list = list(item.update_history or [])
|
||||
history_list.append(history_entry)
|
||||
|
||||
item.value = new_value
|
||||
item.version = item.version + 1
|
||||
item.is_filled = True
|
||||
item.update_history = history_list
|
||||
item.correction_reason = reason
|
||||
await self.db.flush()
|
||||
|
||||
logger.info(
|
||||
f"更正信息项: session={session_id} name={name} "
|
||||
f"old={actual_old} new={new_value} v={item.version}"
|
||||
)
|
||||
return item
|
||||
|
||||
async def check_dependencies(
|
||||
self, session_id: str, item_key: str
|
||||
) -> List[dict]:
|
||||
"""检查信息项依赖关系。
|
||||
|
||||
查询所有 derived_from 包含该 item_key 的其他信息项。
|
||||
"""
|
||||
# 获取所有信息项
|
||||
items = await self._get_items(session_id)
|
||||
|
||||
warnings = []
|
||||
for item in items:
|
||||
if item.name == item_key:
|
||||
continue
|
||||
if item.derived_from and item_key in item.derived_from:
|
||||
warnings.append({
|
||||
"item_key": item.name,
|
||||
"current_value": item.value,
|
||||
"derived_from": item_key,
|
||||
"message": f"信息项「{item.name}」依赖「{item_key}」,可能需要同步更新",
|
||||
})
|
||||
|
||||
return warnings
|
||||
|
||||
async def get_correction_history(
|
||||
self, session_id: str
|
||||
) -> List[dict]:
|
||||
"""获取更正历史(基于快照列表)。"""
|
||||
snapshots = await self.snapshot_service.get_snapshot_history(session_id)
|
||||
return [
|
||||
{
|
||||
"snapshot_id": s.id,
|
||||
"trigger_item_key": s.trigger_item_key,
|
||||
"correction_ids": s.correction_ids,
|
||||
"is_undone": s.is_undone,
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||
"snapshot_data": s.snapshot_data,
|
||||
}
|
||||
for s in snapshots
|
||||
]
|
||||
|
||||
async def get_version_chain(
|
||||
self, session_id: str, item_name: str
|
||||
) -> List[dict]:
|
||||
"""获取信息项版本链。"""
|
||||
item = await self._get_item(session_id, item_name)
|
||||
if item is None:
|
||||
return []
|
||||
|
||||
chain = []
|
||||
# 从 update_history 构建版本链
|
||||
for h in (item.update_history or []):
|
||||
chain.append({
|
||||
"version": h["version"],
|
||||
"value": h["old_value"],
|
||||
"new_value": h["new_value"],
|
||||
"action": h["action"],
|
||||
"reason": h.get("reason"),
|
||||
"timestamp": h.get("timestamp"),
|
||||
})
|
||||
|
||||
# 当前版本
|
||||
chain.append({
|
||||
"version": item.version,
|
||||
"value": item.value,
|
||||
"new_value": item.value,
|
||||
"action": "current",
|
||||
"reason": item.correction_reason,
|
||||
"timestamp": item.updated_at.isoformat() if item.updated_at else None,
|
||||
})
|
||||
|
||||
return chain
|
||||
|
||||
async def get_version_diff(
|
||||
self, session_id: str, item_name: str, v1: int, v2: int
|
||||
) -> dict:
|
||||
"""版本对比(委托 SnapshotService)。"""
|
||||
return await self.snapshot_service.get_version_diff(
|
||||
session_id, item_name, v1, v2
|
||||
)
|
||||
|
||||
async def undo_correction(self, session_id: str) -> dict:
|
||||
"""撤销最近一次更正(委托 SnapshotService)。"""
|
||||
return await self.snapshot_service.undo_correction(session_id)
|
||||
|
||||
async def _get_items(self, session_id: str) -> List[InformationItem]:
|
||||
"""获取会话下所有信息项。"""
|
||||
stmt = select(InformationItem).where(
|
||||
InformationItem.session_id == session_id
|
||||
)
|
||||
return list((await self.db.execute(stmt)).scalars().all())
|
||||
|
||||
async def _get_item(
|
||||
self, session_id: str, name: str
|
||||
) -> Optional[InformationItem]:
|
||||
"""按名称获取单个信息项。"""
|
||||
stmt = select(InformationItem).where(
|
||||
InformationItem.session_id == session_id,
|
||||
InformationItem.name == name,
|
||||
)
|
||||
return (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
@@ -0,0 +1,336 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 复杂场景重构 信息项管理服务
|
||||
# =============================================================================
|
||||
# 说明:管理对话中收集的信息项(InformationItem),支持创建、查询、更正、
|
||||
# 补充、锁定及下游影响检测。更正时保留变更历史(version + update_history),
|
||||
# 补充时按修饰符决定追加或覆盖。动作执行后锁定含「固定」修饰符的信息项。
|
||||
#
|
||||
# 关键逻辑:
|
||||
# 1. correct_value() — 检查 is_locked,旧值存入 update_history,value 覆盖,version+1
|
||||
# 2. supplement_value() — 不存在则创建(modifiers=["增量"]);含「增量」则追加,否则覆盖
|
||||
# 3. lock_items_for_action() — 动作执行后锁定含「固定」修饰符的关联信息项
|
||||
# 4. check_downstream_impact() — 检查更正是否影响已生成的待执行动作
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.constants import (
|
||||
AutomationErrorCode,
|
||||
INFO_MODIFIER_FIXED,
|
||||
INFO_MODIFIER_INCREMENTAL,
|
||||
INFO_MODIFIER_REQUIRED,
|
||||
)
|
||||
from app.models.automation import AutoAction, InformationItem
|
||||
from app.services.automation.exception_handler import AutomationException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 动作已执行的状态集合(执行后锁定固定信息项)
|
||||
_ACTION_EXECUTED_STATUSES = {"success", "failed", "rejected", "skipped"}
|
||||
|
||||
|
||||
class InformationItemService:
|
||||
"""信息项管理服务。
|
||||
|
||||
提供信息项的 CRUD、更正、补充、锁定及下游影响检测能力。
|
||||
"""
|
||||
|
||||
def __init__(self, db: Any, redis: Any = None):
|
||||
self.db = db
|
||||
self.redis = redis
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 创建
|
||||
# --------------------------------------------------------------------------
|
||||
async def create_item(
|
||||
self,
|
||||
session_id: str,
|
||||
name: str,
|
||||
value: str,
|
||||
modifiers: Optional[List[str]] = None,
|
||||
) -> InformationItem:
|
||||
"""创建信息项。
|
||||
|
||||
Args:
|
||||
session_id: 关联会话ID
|
||||
name: 信息项名称
|
||||
value: 初始值
|
||||
modifiers: 修饰符列表,如 ["固定", "必需"]
|
||||
Returns:
|
||||
InformationItem: 创建的信息项
|
||||
"""
|
||||
item = InformationItem(
|
||||
session_id=session_id,
|
||||
name=name,
|
||||
value=value,
|
||||
modifiers=modifiers or [],
|
||||
is_filled=bool(value),
|
||||
is_locked=False,
|
||||
version=1,
|
||||
update_history=[],
|
||||
)
|
||||
self.db.add(item)
|
||||
await self.db.flush()
|
||||
logger.info(f"创建信息项: session={session_id} name={name} value={value}")
|
||||
return item
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 查询
|
||||
# --------------------------------------------------------------------------
|
||||
async def get_items(self, session_id: str) -> List[InformationItem]:
|
||||
"""获取会话下所有信息项。"""
|
||||
stmt = select(InformationItem).where(
|
||||
InformationItem.session_id == session_id
|
||||
)
|
||||
return list((await self.db.execute(stmt)).scalars().all())
|
||||
|
||||
async def get_item(
|
||||
self, session_id: str, name: str
|
||||
) -> Optional[InformationItem]:
|
||||
"""按名称获取单个信息项。"""
|
||||
stmt = select(InformationItem).where(
|
||||
InformationItem.session_id == session_id,
|
||||
InformationItem.name == name,
|
||||
)
|
||||
return (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 更正(CORRECT 意图)
|
||||
# --------------------------------------------------------------------------
|
||||
async def correct_value(
|
||||
self,
|
||||
session_id: str,
|
||||
name: str,
|
||||
new_value: str,
|
||||
old_value: Optional[str] = None,
|
||||
) -> InformationItem:
|
||||
"""更正信息项值。
|
||||
|
||||
逻辑:
|
||||
- 检查 is_locked,若 True → 抛出 INFO_ITEM_LOCKED 异常
|
||||
- 旧值存入 update_history
|
||||
- value = new_value, version += 1, is_filled = True
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
name: 字段名
|
||||
new_value: 新值
|
||||
old_value: 旧值(可选,Dify 提取时可能有)
|
||||
Returns:
|
||||
InformationItem: 更正后的信息项
|
||||
Raises:
|
||||
AutomationException: 信息项已锁定
|
||||
"""
|
||||
item = await self.get_item(session_id, name)
|
||||
if item is None:
|
||||
# 信息项不存在 → 创建(视为首次填写)
|
||||
item = await self.create_item(session_id, name, new_value, [])
|
||||
return item
|
||||
|
||||
if item.is_locked:
|
||||
raise AutomationException(
|
||||
AutomationErrorCode.INFO_ITEM_LOCKED,
|
||||
f"信息项「{name}」已锁定,不可更正",
|
||||
)
|
||||
|
||||
# 记录变更历史
|
||||
actual_old_value = old_value if old_value is not None else item.value
|
||||
history_entry = {
|
||||
"version": item.version,
|
||||
"old_value": actual_old_value,
|
||||
"new_value": new_value,
|
||||
"action": "correct",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
history_list = list(item.update_history or [])
|
||||
history_list.append(history_entry)
|
||||
|
||||
item.value = new_value
|
||||
item.version = item.version + 1
|
||||
item.is_filled = True
|
||||
item.update_history = history_list
|
||||
await self.db.flush()
|
||||
|
||||
logger.info(
|
||||
f"更正信息项: session={session_id} name={name} "
|
||||
f"old={actual_old_value} new={new_value} v={item.version}"
|
||||
)
|
||||
return item
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 补充(SUPPLEMENT 意图)
|
||||
# --------------------------------------------------------------------------
|
||||
async def supplement_value(
|
||||
self,
|
||||
session_id: str,
|
||||
name: str,
|
||||
value: str,
|
||||
) -> InformationItem:
|
||||
"""补充信息项值。
|
||||
|
||||
逻辑:
|
||||
- 信息项不存在 → 创建新项(modifiers 默认 ["增量"])
|
||||
- modifiers 含「增量」→ value = value + "; " + new_value(追加)
|
||||
- 否则同 correct 处理(覆盖)
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
name: 字段名
|
||||
value: 补充值
|
||||
Returns:
|
||||
InformationItem: 补充后的信息项
|
||||
"""
|
||||
item = await self.get_item(session_id, name)
|
||||
|
||||
if item is None:
|
||||
# 不存在 → 创建,默认增量修饰符
|
||||
item = await self.create_item(
|
||||
session_id, name, value, [INFO_MODIFIER_INCREMENTAL]
|
||||
)
|
||||
return item
|
||||
|
||||
modifiers = item.modifiers or []
|
||||
old_value = item.value
|
||||
|
||||
if INFO_MODIFIER_INCREMENTAL in modifiers:
|
||||
# 增量修饰符 → 追加
|
||||
new_value = f"{old_value}; {value}" if old_value else value
|
||||
else:
|
||||
# 非增量 → 覆盖(同 correct)
|
||||
if item.is_locked:
|
||||
raise AutomationException(
|
||||
AutomationErrorCode.INFO_ITEM_LOCKED,
|
||||
f"信息项「{name}」已锁定,不可更正",
|
||||
)
|
||||
new_value = value
|
||||
|
||||
# 记录变更历史
|
||||
history_entry = {
|
||||
"version": item.version,
|
||||
"old_value": old_value,
|
||||
"new_value": new_value,
|
||||
"action": "supplement",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
history_list = list(item.update_history or [])
|
||||
history_list.append(history_entry)
|
||||
|
||||
item.value = new_value
|
||||
item.version = item.version + 1
|
||||
item.is_filled = True
|
||||
item.update_history = history_list
|
||||
await self.db.flush()
|
||||
|
||||
logger.info(
|
||||
f"补充信息项: session={session_id} name={name} "
|
||||
f"old={old_value} new={new_value} v={item.version}"
|
||||
)
|
||||
return item
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 锁定(动作执行后调用)
|
||||
# --------------------------------------------------------------------------
|
||||
async def lock_items_for_action(
|
||||
self, session_id: str, action_id: str
|
||||
) -> None:
|
||||
"""动作执行后锁定关联信息项。
|
||||
|
||||
逻辑:
|
||||
- 查找该 action 的 payload 中引用的信息项(通过 payload key 名匹配信息项 name)
|
||||
- 对含「固定」修饰符的信息项设置 is_locked = True
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
action_id: 关联动作ID
|
||||
"""
|
||||
# 查询动作
|
||||
act_stmt = select(AutoAction).where(AutoAction.id == action_id)
|
||||
action = (await self.db.execute(act_stmt)).scalar_one_or_none()
|
||||
if action is None:
|
||||
return
|
||||
|
||||
payload = action.payload or {}
|
||||
# payload 中的 key 名即为信息项 name 的候选集
|
||||
candidate_names = set(payload.keys())
|
||||
|
||||
if not candidate_names:
|
||||
return
|
||||
|
||||
# 查询会话下所有信息项
|
||||
items = await self.get_items(session_id)
|
||||
locked_count = 0
|
||||
for item in items:
|
||||
if item.name in candidate_names and INFO_MODIFIER_FIXED in (item.modifiers or []):
|
||||
if not item.is_locked:
|
||||
item.is_locked = True
|
||||
locked_count += 1
|
||||
|
||||
if locked_count > 0:
|
||||
await self.db.flush()
|
||||
logger.info(
|
||||
f"锁定信息项: session={session_id} action={action_id} "
|
||||
f"locked={locked_count}"
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 下游影响检测
|
||||
# --------------------------------------------------------------------------
|
||||
async def check_downstream_impact(
|
||||
self, session_id: str, field_name: str
|
||||
) -> bool:
|
||||
"""检查更正是否影响已生成的待执行动作。
|
||||
|
||||
逻辑:
|
||||
- 查询 auto_actions 表中 status in ('pending', 'await_approval') 的动作
|
||||
- 检查动作 payload 是否引用了被更正的字段
|
||||
- 返回 True/False
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
field_name: 被更正的字段名
|
||||
Returns:
|
||||
bool: True 表示影响下游动作
|
||||
"""
|
||||
stmt = select(AutoAction).where(
|
||||
AutoAction.session_id == session_id,
|
||||
AutoAction.status.in_(["pending", "await_approval"]),
|
||||
)
|
||||
pending_actions = list((await self.db.execute(stmt)).scalars().all())
|
||||
|
||||
for action in pending_actions:
|
||||
payload = action.payload or {}
|
||||
if field_name in payload:
|
||||
logger.info(
|
||||
f"下游影响检测: session={session_id} field={field_name} "
|
||||
f"affected_action={action.id}"
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 获取未填写的必需信息项
|
||||
# --------------------------------------------------------------------------
|
||||
async def get_pending_required_items(self, session_id: str) -> List[str]:
|
||||
"""获取未填写的必需信息项名称列表。
|
||||
|
||||
用于恢复会话时检查必需信息项完整性。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
Returns:
|
||||
List[str]: 未填写的必需信息项名称列表
|
||||
"""
|
||||
items = await self.get_items(session_id)
|
||||
pending: List[str] = []
|
||||
for item in items:
|
||||
modifiers = item.modifiers or []
|
||||
if INFO_MODIFIER_REQUIRED in modifiers and not item.is_filled:
|
||||
pending.append(item.name)
|
||||
return pending
|
||||
@@ -1,8 +1,10 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 意图识别
|
||||
# 企微IT智能服务台 — 自动化 意图识别(含全局意图)
|
||||
# =============================================================================
|
||||
# 说明:调用 Dify 识别员工诉求命中哪个自动化场景;Dify 未配置时走关键词兜底,
|
||||
# 保证 P0 四个场景在无真实 Dify 环境下也能跑通闭环。
|
||||
# 复杂场景重构:新增全局意图检测(PAUSE/RESUME_TASK/CORRECT/SUPPLEMENT),
|
||||
# detect() 内部先调全局意图检测,命中则返回全局意图,跳过场景识别。
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,17 +19,31 @@ 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}。
|
||||
"""识别意图,返回包含 global_intent 的完整结果。
|
||||
|
||||
优先走 Dify;若 Dify 未配置或调用失败,使用关键词兜底。
|
||||
优先检测全局对话控制意图(pause/resume_task/correct/supplement),
|
||||
命中则直接返回全局意图,跳过场景识别;
|
||||
未命中则走原有场景识别流程。
|
||||
|
||||
Returns:
|
||||
Dict: {global_intent, scenario_key, confidence, corrected_field,
|
||||
old_value, new_value, supplement_field, supplement_value,
|
||||
raw, error}
|
||||
"""
|
||||
# 1. 先检测全局意图
|
||||
global_result = await self.detect_global_intent(description)
|
||||
if global_result.get("global_intent") is not None:
|
||||
# 命中全局意图 → 直接返回,跳过场景识别
|
||||
return global_result
|
||||
|
||||
# 2. 未命中全局意图 → 走原场景识别
|
||||
client: Optional[DifyClient] = None
|
||||
try:
|
||||
client = await build_dify_client(audit=self.audit)
|
||||
@@ -37,12 +53,97 @@ class IntentRouter:
|
||||
if client is None:
|
||||
fb = DifyClient._fallback_intent(description)
|
||||
fb["error"] = "dify_not_configured"
|
||||
# 确保全局意图字段存在
|
||||
fb.setdefault("global_intent", None)
|
||||
fb.setdefault("corrected_field", None)
|
||||
fb.setdefault("old_value", None)
|
||||
fb.setdefault("new_value", None)
|
||||
fb.setdefault("supplement_field", None)
|
||||
fb.setdefault("supplement_value", None)
|
||||
return fb
|
||||
|
||||
try:
|
||||
return await client.detect_intent(description, employee_id)
|
||||
result = await client.detect_intent(description, employee_id)
|
||||
# 确保全局意图字段存在(兼容旧版 Dify 返回)
|
||||
result.setdefault("global_intent", None)
|
||||
result.setdefault("corrected_field", None)
|
||||
result.setdefault("old_value", None)
|
||||
result.setdefault("new_value", None)
|
||||
result.setdefault("supplement_field", None)
|
||||
result.setdefault("supplement_value", None)
|
||||
return result
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"Dify 意图识别异常,转关键词兜底: {e}")
|
||||
fb = DifyClient._fallback_intent(description)
|
||||
fb["error"] = str(e)
|
||||
fb.setdefault("global_intent", None)
|
||||
fb.setdefault("corrected_field", None)
|
||||
fb.setdefault("old_value", None)
|
||||
fb.setdefault("new_value", None)
|
||||
fb.setdefault("supplement_field", None)
|
||||
fb.setdefault("supplement_value", None)
|
||||
return fb
|
||||
|
||||
async def detect_global_intent(self, text: str) -> Dict[str, Any]:
|
||||
"""检测全局对话控制意图(pause/resume_task/correct/supplement)。
|
||||
|
||||
优先调用 Dify(复用现有客户端),Prompt 中增加全局意图判断;
|
||||
Dify 不可用时走关键词兜底(使用 GLOBAL_INTENT_KEYWORDS)。
|
||||
|
||||
Returns:
|
||||
Dict: {global_intent, scenario_key, confidence, corrected_field,
|
||||
old_value, new_value, supplement_field, supplement_value,
|
||||
raw, error}
|
||||
"""
|
||||
# 尝试 Dify
|
||||
client: Optional[DifyClient] = None
|
||||
try:
|
||||
client = await build_dify_client(audit=self.audit)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug(f"构建 Dify 客户端失败,全局意图转关键词兜底: {e}")
|
||||
|
||||
if client is not None:
|
||||
try:
|
||||
result = await client.detect_intent(text, "")
|
||||
# detect_intent 已返回 global_intent,直接使用
|
||||
return result
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"Dify 全局意图识别异常,转关键词兜底: {e}")
|
||||
|
||||
# 关键词兜底
|
||||
return self._keyword_fallback_global(text)
|
||||
|
||||
def _keyword_fallback_global(self, text: str) -> Dict[str, Any]:
|
||||
"""全局意图关键词兜底。"""
|
||||
lower_text = (text or "").lower()
|
||||
try:
|
||||
from app.constants import GLOBAL_INTENT_KEYWORDS
|
||||
|
||||
for intent, keywords in GLOBAL_INTENT_KEYWORDS.items():
|
||||
if any(kw.lower() in lower_text for kw in keywords):
|
||||
return {
|
||||
"global_intent": intent,
|
||||
"scenario_key": None,
|
||||
"confidence": 0.6,
|
||||
"corrected_field": None,
|
||||
"old_value": None,
|
||||
"new_value": None,
|
||||
"supplement_field": None,
|
||||
"supplement_value": None,
|
||||
"raw": "",
|
||||
"error": "fallback",
|
||||
}
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return {
|
||||
"global_intent": None,
|
||||
"scenario_key": None,
|
||||
"confidence": 0.0,
|
||||
"corrected_field": None,
|
||||
"old_value": None,
|
||||
"new_value": None,
|
||||
"supplement_field": None,
|
||||
"supplement_value": None,
|
||||
"raw": "",
|
||||
"error": "fallback",
|
||||
}
|
||||
|
||||
@@ -20,9 +20,14 @@ from app.constants import (
|
||||
AUTOMATION_SILENT_CLOSE_TTL,
|
||||
AUTOMATION_WS_ACTION_REQUIRED,
|
||||
AUTOMATION_WS_ERROR,
|
||||
AUTOMATION_WS_INFO_CORRECTED,
|
||||
AUTOMATION_WS_INFO_SUPPLEMENTED,
|
||||
AUTOMATION_WS_PAUSED,
|
||||
AUTOMATION_WS_PROGRESS,
|
||||
AUTOMATION_WS_RESOLVED,
|
||||
AUTOMATION_WS_RESUMED,
|
||||
AUTOMATION_WS_TAKEOVER,
|
||||
AUTOMATION_WS_TIMEOUT_CLOSED,
|
||||
)
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
|
||||
@@ -150,6 +155,139 @@ async def publish_error(session_id: str, code: int, message: str) -> None:
|
||||
await _publish(AUTOMATION_WS_ERROR, session_id, {"code": code, "message": message})
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 复杂场景重构第一阶段 — 新增 WS 事件推送
|
||||
# ==========================================================================
|
||||
async def publish_paused(
|
||||
session_id: str,
|
||||
title: str,
|
||||
paused_at: str,
|
||||
resume_hint: str = "",
|
||||
) -> None:
|
||||
"""推送会话暂停事件。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
title: 会话标题
|
||||
paused_at: 暂停时间(ISO 字符串)
|
||||
resume_hint: 恢复提示文案
|
||||
"""
|
||||
await _publish(
|
||||
AUTOMATION_WS_PAUSED,
|
||||
session_id,
|
||||
{
|
||||
"session_id": session_id,
|
||||
"title": title,
|
||||
"paused_at": paused_at,
|
||||
"resume_hint": resume_hint,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def publish_resumed(
|
||||
session_id: str,
|
||||
title: str,
|
||||
resumed_at: str,
|
||||
current_step: str = "",
|
||||
) -> None:
|
||||
"""推送会话恢复事件。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
title: 会话标题
|
||||
resumed_at: 恢复时间(ISO 字符串)
|
||||
current_step: 当前步骤描述
|
||||
"""
|
||||
await _publish(
|
||||
AUTOMATION_WS_RESUMED,
|
||||
session_id,
|
||||
{
|
||||
"session_id": session_id,
|
||||
"title": title,
|
||||
"resumed_at": resumed_at,
|
||||
"current_step": current_step,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def publish_timeout_closed(
|
||||
session_id: str,
|
||||
closed_at: str,
|
||||
reason: str = "",
|
||||
) -> None:
|
||||
"""推送暂停超时关闭事件。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
closed_at: 关闭时间(ISO 字符串)
|
||||
reason: 关闭原因
|
||||
"""
|
||||
await _publish(
|
||||
AUTOMATION_WS_TIMEOUT_CLOSED,
|
||||
session_id,
|
||||
{
|
||||
"session_id": session_id,
|
||||
"closed_at": closed_at,
|
||||
"reason": reason,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def publish_info_corrected(
|
||||
session_id: str,
|
||||
field: str,
|
||||
old_value: str,
|
||||
new_value: str,
|
||||
version: int,
|
||||
) -> None:
|
||||
"""推送信息更正事件。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
field: 更正的字段名
|
||||
old_value: 旧值
|
||||
new_value: 新值
|
||||
version: 更正后的版本号
|
||||
"""
|
||||
await _publish(
|
||||
AUTOMATION_WS_INFO_CORRECTED,
|
||||
session_id,
|
||||
{
|
||||
"session_id": session_id,
|
||||
"field": field,
|
||||
"old_value": old_value,
|
||||
"new_value": new_value,
|
||||
"version": version,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def publish_info_supplemented(
|
||||
session_id: str,
|
||||
field: str,
|
||||
supplement_value: str,
|
||||
new_value: str,
|
||||
) -> None:
|
||||
"""推送信息补充事件。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
field: 补充的字段名
|
||||
supplement_value: 本次补充的值
|
||||
new_value: 补充后的完整值
|
||||
"""
|
||||
await _publish(
|
||||
AUTOMATION_WS_INFO_SUPPLEMENTED,
|
||||
session_id,
|
||||
{
|
||||
"session_id": session_id,
|
||||
"field": field,
|
||||
"supplement_value": supplement_value,
|
||||
"new_value": new_value,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def schedule_silent_close(
|
||||
session_id: str, ttl: int = AUTOMATION_SILENT_CLOSE_TTL, on_expire=None
|
||||
) -> None:
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -18,23 +19,40 @@ from typing import Any, Dict, List, Optional
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import settings
|
||||
from app.constants import AutomationErrorCode
|
||||
from app.constants import (
|
||||
AutomationErrorCode,
|
||||
GLOBAL_INTENT_CORRECT,
|
||||
GLOBAL_INTENT_PAUSE,
|
||||
GLOBAL_INTENT_RESUME_TASK,
|
||||
GLOBAL_INTENT_SUPPLEMENT,
|
||||
PAUSE_TIMEOUT_HOURS,
|
||||
REDIS_KEY_PAUSED_SESSIONS,
|
||||
REDIS_KEY_RESUME_POINT,
|
||||
RESUME_POINT_REDIS_TTL,
|
||||
)
|
||||
from app.database import _get_session_factory
|
||||
from app.models.automation import (
|
||||
ApprovalTicket,
|
||||
AutoAction,
|
||||
AutoSession,
|
||||
InformationItem,
|
||||
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.information_item_service import InformationItemService
|
||||
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_info_corrected,
|
||||
publish_info_supplemented,
|
||||
publish_paused,
|
||||
publish_progress,
|
||||
publish_resumed,
|
||||
publish_takeover,
|
||||
publish_timeout_closed,
|
||||
)
|
||||
from app.services.automation import DEFAULT_SCENARIO_CONFIGS
|
||||
|
||||
@@ -124,7 +142,11 @@ class AutoSessionService:
|
||||
# 编排主流程
|
||||
# --------------------------------------------------------------------------
|
||||
async def start(self, session_id: str) -> None:
|
||||
"""编排:意图识别 → 场景校验 → 映射 → 计划 → 执行。"""
|
||||
"""编排:意图识别 → 全局意图分流 → 场景校验 → 映射 → 计划 → 执行。
|
||||
|
||||
复杂场景重构:意图识别后先检查 global_intent,
|
||||
命中 pause/resume_task/correct/supplement 时分流到对应方法。
|
||||
"""
|
||||
session = await self.get_session(session_id)
|
||||
if session is None:
|
||||
logger.warning(f"start 会话不存在: {session_id}")
|
||||
@@ -139,12 +161,12 @@ class AutoSessionService:
|
||||
|
||||
description = (session.meta or {}).get("description", "")
|
||||
|
||||
# 1. 意图识别
|
||||
# 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)}
|
||||
intent = {"global_intent": None, "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
|
||||
@@ -155,6 +177,29 @@ class AutoSessionService:
|
||||
f"识别场景: {session.scenario_key or '未知'}(置信度 {session.confidence:.2f})",
|
||||
)
|
||||
|
||||
# 1.5 全局意图分流(复杂场景重构)
|
||||
global_intent = intent.get("global_intent")
|
||||
if global_intent == GLOBAL_INTENT_PAUSE:
|
||||
await self.pause_session(session_id, reason="用户主动暂停")
|
||||
return
|
||||
if global_intent == GLOBAL_INTENT_RESUME_TASK:
|
||||
# 当前会话刚创建,恢复逻辑应指向已有暂停会话
|
||||
await self.resume_session(session.employee_id)
|
||||
return
|
||||
if global_intent == GLOBAL_INTENT_CORRECT:
|
||||
field = intent.get("corrected_field") or ""
|
||||
new_value = intent.get("new_value") or ""
|
||||
old_value = intent.get("old_value")
|
||||
if field and new_value:
|
||||
await self.correct_info(session_id, field, new_value, old_value)
|
||||
return
|
||||
if global_intent == GLOBAL_INTENT_SUPPLEMENT:
|
||||
field = intent.get("supplement_field") or ""
|
||||
value = intent.get("supplement_value") or ""
|
||||
if field and value:
|
||||
await self.supplement_info(session_id, field, value)
|
||||
return
|
||||
|
||||
# 2. 置信度门槛 → 低置信度转人工
|
||||
thresholds = settings.get_automation_thresholds()
|
||||
confidence_min = float(thresholds.get("confidence_min", 0.6))
|
||||
@@ -310,6 +355,469 @@ class AutoSessionService:
|
||||
session.closed_by = "system(auto)"
|
||||
await self.db.flush()
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 复杂场景重构:暂停 / 恢复 / 更正 / 补充 / 坐席操作
|
||||
# --------------------------------------------------------------------------
|
||||
async def pause_session(
|
||||
self, session_id: str, reason: Optional[str] = None
|
||||
) -> AutoSession:
|
||||
"""暂停会话。
|
||||
|
||||
校验状态:running / await_approval → paused;终态不可暂停。
|
||||
构建恢复点快照存入 Redis,推送 WS 暂停事件。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
reason: 暂停原因
|
||||
Returns:
|
||||
AutoSession: 暂停后的会话
|
||||
Raises:
|
||||
AutomationException: 会话不存在或不可暂停
|
||||
"""
|
||||
session = await self.get_session(session_id)
|
||||
if session is None:
|
||||
raise AutomationException(AutomationErrorCode.SESSION_NOT_FOUND)
|
||||
|
||||
# 终态不可暂停
|
||||
if session.status in ("closed", "handoff", "error"):
|
||||
raise AutomationException(AutomationErrorCode.SESSION_NOT_PAUSABLE)
|
||||
|
||||
# 如果是 await_approval 状态暂停 → 记录到 meta(挂起审批计时)
|
||||
meta = dict(session.meta or {})
|
||||
if session.status == "await_approval" or self._is_awaiting_approval(session):
|
||||
meta["paused_from_approval"] = True
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
session.status = "paused"
|
||||
session.paused_at = now
|
||||
session.meta = meta
|
||||
await self.db.flush()
|
||||
|
||||
# 构建恢复点快照
|
||||
info_svc = InformationItemService(self.db, self.redis)
|
||||
info_items = await info_svc.get_items(session_id)
|
||||
info_items_snapshot = [
|
||||
{
|
||||
"id": item.id,
|
||||
"name": item.name,
|
||||
"value": item.value,
|
||||
"modifiers": item.modifiers or [],
|
||||
"is_filled": item.is_filled,
|
||||
"is_locked": item.is_locked,
|
||||
"version": item.version,
|
||||
}
|
||||
for item in info_items
|
||||
]
|
||||
step_desc = await self._get_current_step_desc(session)
|
||||
resume_point = {
|
||||
"title": session.title,
|
||||
"scenario_key": session.scenario_key,
|
||||
"current_action_id": session.current_action_id,
|
||||
"step_desc": step_desc,
|
||||
"info_items": info_items_snapshot,
|
||||
"paused_at": now.isoformat(),
|
||||
}
|
||||
|
||||
# Redis 存储恢复点 + 暂停会话集合
|
||||
if self.redis:
|
||||
try:
|
||||
import json
|
||||
resume_key = REDIS_KEY_RESUME_POINT.format(session_id=session_id)
|
||||
await self.redis.setex(
|
||||
resume_key,
|
||||
RESUME_POINT_REDIS_TTL,
|
||||
json.dumps(resume_point, ensure_ascii=False),
|
||||
)
|
||||
paused_key = REDIS_KEY_PAUSED_SESSIONS.format(
|
||||
employee_id=session.employee_id
|
||||
)
|
||||
await self.redis.sadd(paused_key, session_id)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"Redis 存储恢复点失败 session={session_id}: {e}")
|
||||
|
||||
# 推送 WS 暂停事件
|
||||
resume_hint = "需要继续时跟我说一声「继续」就好"
|
||||
await publish_paused(
|
||||
session_id=session_id,
|
||||
title=session.title,
|
||||
paused_at=now.isoformat(),
|
||||
resume_hint=resume_hint,
|
||||
)
|
||||
|
||||
logger.info(f"暂停会话: session={session_id} reason={reason or ''}")
|
||||
return session
|
||||
|
||||
async def resume_session(
|
||||
self,
|
||||
employee_id: str,
|
||||
session_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""恢复暂停的会话。
|
||||
|
||||
若 session_id 为 None → 查询该员工的暂停会话列表。
|
||||
若多个暂停会话 → 返回列表供前端选择(不直接恢复)。
|
||||
若单个 → 直接恢复。
|
||||
|
||||
Args:
|
||||
employee_id: 员工ID
|
||||
session_id: 指定恢复的会话ID(可选)
|
||||
Returns:
|
||||
Dict: {"session": AutoSession, "resume_point": dict, "need_select": bool, "paused_list": list}
|
||||
Raises:
|
||||
AutomationException: 会话不存在或不可恢复
|
||||
"""
|
||||
# 未指定 session_id → 查询暂停会话列表
|
||||
if session_id is None:
|
||||
paused_list = await self.list_paused_sessions(employee_id)
|
||||
if len(paused_list) == 0:
|
||||
return {"session": None, "resume_point": None, "need_select": False, "paused_list": []}
|
||||
if len(paused_list) > 1:
|
||||
return {
|
||||
"session": None,
|
||||
"resume_point": None,
|
||||
"need_select": True,
|
||||
"paused_list": paused_list,
|
||||
}
|
||||
# 单个暂停会话 → 直接恢复
|
||||
session_id = paused_list[0]["session_id"]
|
||||
|
||||
session = await self.get_session(session_id)
|
||||
if session is None:
|
||||
raise AutomationException(AutomationErrorCode.SESSION_NOT_FOUND)
|
||||
|
||||
if session.status != "paused":
|
||||
if session.status == "closed":
|
||||
raise AutomationException(
|
||||
AutomationErrorCode.SESSION_NOT_RESUMABLE,
|
||||
"该任务已超时关闭,请重新发起",
|
||||
)
|
||||
raise AutomationException(AutomationErrorCode.SESSION_NOT_RESUMABLE)
|
||||
|
||||
# 从 Redis 加载恢复点
|
||||
resume_point = await self._load_resume_point(session_id)
|
||||
|
||||
# 恢复状态
|
||||
now = datetime.now(timezone.utc)
|
||||
session.status = "running"
|
||||
session.paused_at = None
|
||||
await self.db.flush()
|
||||
|
||||
# Redis 清理
|
||||
if self.redis:
|
||||
try:
|
||||
resume_key = REDIS_KEY_RESUME_POINT.format(session_id=session_id)
|
||||
await self.redis.delete(resume_key)
|
||||
paused_key = REDIS_KEY_PAUSED_SESSIONS.format(
|
||||
employee_id=employee_id
|
||||
)
|
||||
await self.redis.srem(paused_key, session_id)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"Redis 清理恢复点失败 session={session_id}: {e}")
|
||||
|
||||
# 检查必需信息项完整性
|
||||
info_svc = InformationItemService(self.db, self.redis)
|
||||
pending_items = await info_svc.get_pending_required_items(session_id)
|
||||
|
||||
# 推送 WS 恢复事件
|
||||
current_step = resume_point.get("step_desc", "") if resume_point else ""
|
||||
await publish_resumed(
|
||||
session_id=session_id,
|
||||
title=session.title,
|
||||
resumed_at=now.isoformat(),
|
||||
current_step=current_step,
|
||||
)
|
||||
|
||||
# 若信息完整 → 续行执行
|
||||
if not pending_items:
|
||||
executor = ActionExecutor(self.db, self.redis, audit=self.audit)
|
||||
asyncio.create_task(self._run_executor(session_id))
|
||||
|
||||
logger.info(f"恢复会话: session={session_id} pending_items={pending_items}")
|
||||
return {
|
||||
"session": session,
|
||||
"resume_point": resume_point,
|
||||
"need_select": False,
|
||||
"paused_list": [],
|
||||
"pending_items": pending_items,
|
||||
}
|
||||
|
||||
async def list_paused_sessions(self, employee_id: str) -> List[Dict[str, Any]]:
|
||||
"""获取员工的暂停会话列表。
|
||||
|
||||
Args:
|
||||
employee_id: 员工ID
|
||||
Returns:
|
||||
List[Dict]: 暂停会话列表项(含暂停时长)
|
||||
"""
|
||||
stmt = select(AutoSession).where(
|
||||
AutoSession.employee_id == employee_id,
|
||||
AutoSession.status == "paused",
|
||||
).order_by(AutoSession.paused_at.desc())
|
||||
sessions = list((await self.db.execute(stmt)).scalars().all())
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
result: List[Dict[str, Any]] = []
|
||||
for s in sessions:
|
||||
paused_at = s.paused_at or s.updated_at
|
||||
duration = self._format_duration(paused_at, now) if paused_at else ""
|
||||
result.append({
|
||||
"session_id": s.id,
|
||||
"title": s.title,
|
||||
"scenario_key": s.scenario_key,
|
||||
"paused_at": paused_at.isoformat() if paused_at else None,
|
||||
"paused_duration": duration,
|
||||
})
|
||||
return result
|
||||
|
||||
async def correct_info(
|
||||
self,
|
||||
session_id: str,
|
||||
field: str,
|
||||
new_value: str,
|
||||
old_value: Optional[str] = None,
|
||||
) -> InformationItem:
|
||||
"""信息更正。
|
||||
|
||||
委托 InformationItemService.correct_value(),检查下游影响,
|
||||
推送 WS 更正事件。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
field: 更正的字段名
|
||||
new_value: 新值
|
||||
old_value: 旧值(可选)
|
||||
Returns:
|
||||
InformationItem: 更正后的信息项
|
||||
"""
|
||||
info_svc = InformationItemService(self.db, self.redis)
|
||||
item = await info_svc.correct_value(session_id, field, new_value, old_value)
|
||||
|
||||
# 检查下游影响
|
||||
has_impact = await info_svc.check_downstream_impact(session_id, field)
|
||||
if has_impact:
|
||||
logger.info(f"更正影响下游动作: session={session_id} field={field}")
|
||||
# TODO: 重新校验映射/动作计划(后续阶段实现)
|
||||
|
||||
# 推送 WS 更正事件
|
||||
actual_old_value = old_value
|
||||
if not actual_old_value and item.update_history:
|
||||
actual_old_value = item.update_history[-1].get("old_value", "")
|
||||
await publish_info_corrected(
|
||||
session_id=session_id,
|
||||
field=field,
|
||||
old_value=actual_old_value or "",
|
||||
new_value=new_value,
|
||||
version=item.version,
|
||||
)
|
||||
|
||||
logger.info(f"更正信息: session={session_id} field={field} v={item.version}")
|
||||
return item
|
||||
|
||||
async def supplement_info(
|
||||
self,
|
||||
session_id: str,
|
||||
field: str,
|
||||
value: str,
|
||||
) -> InformationItem:
|
||||
"""信息补充。
|
||||
|
||||
委托 InformationItemService.supplement_value(),推送 WS 补充事件。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
field: 补充的字段名
|
||||
value: 补充值
|
||||
Returns:
|
||||
InformationItem: 补充后的信息项
|
||||
"""
|
||||
info_svc = InformationItemService(self.db, self.redis)
|
||||
item = await info_svc.supplement_value(session_id, field, value)
|
||||
|
||||
# 推送 WS 补充事件
|
||||
await publish_info_supplemented(
|
||||
session_id=session_id,
|
||||
field=field,
|
||||
supplement_value=value,
|
||||
new_value=item.value,
|
||||
)
|
||||
|
||||
logger.info(f"补充信息: session={session_id} field={field} v={item.version}")
|
||||
return item
|
||||
|
||||
async def agent_resume(
|
||||
self,
|
||||
session_id: str,
|
||||
agent_id: str,
|
||||
note: Optional[str] = None,
|
||||
) -> AutoSession:
|
||||
"""坐席代恢复暂停会话。
|
||||
|
||||
无需员工授权,恢复后标记 closed_by = "agent:{agent_id}(resume)"。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
agent_id: 坐席ID
|
||||
note: 备注
|
||||
Returns:
|
||||
AutoSession: 恢复后的会话
|
||||
Raises:
|
||||
AutomationException: 会话不存在或不可恢复
|
||||
"""
|
||||
session = await self.get_session(session_id)
|
||||
if session is None:
|
||||
raise AutomationException(AutomationErrorCode.SESSION_NOT_FOUND)
|
||||
|
||||
if session.status != "paused":
|
||||
raise AutomationException(AutomationErrorCode.SESSION_NOT_RESUMABLE)
|
||||
|
||||
# 从 Redis 加载恢复点
|
||||
resume_point = await self._load_resume_point(session_id)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
session.status = "running"
|
||||
session.paused_at = None
|
||||
session.agent_id = agent_id
|
||||
session.closed_by = f"agent:{agent_id}(resume)"
|
||||
await self.db.flush()
|
||||
|
||||
# Redis 清理
|
||||
if self.redis:
|
||||
try:
|
||||
resume_key = REDIS_KEY_RESUME_POINT.format(session_id=session_id)
|
||||
await self.redis.delete(resume_key)
|
||||
paused_key = REDIS_KEY_PAUSED_SESSIONS.format(
|
||||
employee_id=session.employee_id
|
||||
)
|
||||
await self.redis.srem(paused_key, session_id)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"Redis 清理恢复点失败 session={session_id}: {e}")
|
||||
|
||||
# 推送 WS 恢复事件
|
||||
current_step = resume_point.get("step_desc", "") if resume_point else ""
|
||||
await publish_resumed(
|
||||
session_id=session_id,
|
||||
title=session.title,
|
||||
resumed_at=now.isoformat(),
|
||||
current_step=f"[坐席代恢复] {current_step}",
|
||||
)
|
||||
|
||||
# 续行执行
|
||||
asyncio.create_task(self._run_executor(session_id))
|
||||
|
||||
logger.info(f"坐席代恢复: session={session_id} agent={agent_id} note={note or ''}")
|
||||
return session
|
||||
|
||||
async def agent_close(
|
||||
self,
|
||||
session_id: str,
|
||||
agent_id: str,
|
||||
note: Optional[str] = None,
|
||||
) -> AutoSession:
|
||||
"""坐席手动关闭暂停会话。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
agent_id: 坐席ID
|
||||
note: 备注
|
||||
Returns:
|
||||
AutoSession: 关闭后的会话
|
||||
Raises:
|
||||
AutomationException: 会话不存在或不可关闭
|
||||
"""
|
||||
session = await self.get_session(session_id)
|
||||
if session is None:
|
||||
raise AutomationException(AutomationErrorCode.SESSION_NOT_FOUND)
|
||||
|
||||
if session.status != "paused":
|
||||
raise AutomationException(
|
||||
AutomationErrorCode.SESSION_NOT_RESUMABLE,
|
||||
"仅暂停状态的会话可由坐席关闭",
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
session.status = "closed"
|
||||
session.closed_by = f"agent:{agent_id}(close)"
|
||||
session.agent_id = agent_id
|
||||
await self.db.flush()
|
||||
|
||||
# Redis 清理
|
||||
if self.redis:
|
||||
try:
|
||||
resume_key = REDIS_KEY_RESUME_POINT.format(session_id=session_id)
|
||||
await self.redis.delete(resume_key)
|
||||
paused_key = REDIS_KEY_PAUSED_SESSIONS.format(
|
||||
employee_id=session.employee_id
|
||||
)
|
||||
await self.redis.srem(paused_key, session_id)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"Redis 清理恢复点失败 session={session_id}: {e}")
|
||||
|
||||
logger.info(f"坐席关闭会话: session={session_id} agent={agent_id} note={note or ''}")
|
||||
return session
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 复杂场景重构:内部辅助方法
|
||||
# --------------------------------------------------------------------------
|
||||
async def _load_resume_point(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""从 Redis 加载恢复点快照。"""
|
||||
if not self.redis:
|
||||
return None
|
||||
try:
|
||||
import json
|
||||
resume_key = REDIS_KEY_RESUME_POINT.format(session_id=session_id)
|
||||
raw = await self.redis.get(resume_key)
|
||||
if raw:
|
||||
return json.loads(raw)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"加载恢复点失败 session={session_id}: {e}")
|
||||
return None
|
||||
|
||||
async def _get_current_step_desc(self, session: AutoSession) -> str:
|
||||
"""获取当前步骤描述(用于恢复点)。"""
|
||||
if not session.current_action_id:
|
||||
return "等待开始处置"
|
||||
stmt = select(AutoAction).where(AutoAction.id == session.current_action_id)
|
||||
action = (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
if action:
|
||||
return f"当前步骤:{action.title}({action.status})"
|
||||
return "处置进行中"
|
||||
|
||||
def _is_awaiting_approval(self, session: AutoSession) -> bool:
|
||||
"""检查会话是否有待审批动作。"""
|
||||
# 通过 current_action_id 和 status 间接判断
|
||||
return session.current_action_id is not None and session.status == "paused"
|
||||
|
||||
@staticmethod
|
||||
def _format_duration(start: datetime, end: datetime) -> str:
|
||||
"""格式化时长为人类可读字符串(如 "2h 15min")。"""
|
||||
# SQLite 读取的 datetime 可能是 timezone-naive,统一补上 UTC 时区后再相减
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=timezone.utc)
|
||||
if end.tzinfo is None:
|
||||
end = end.replace(tzinfo=timezone.utc)
|
||||
delta = end - start
|
||||
total_seconds = int(delta.total_seconds())
|
||||
if total_seconds < 0:
|
||||
return ""
|
||||
hours = total_seconds // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
if hours > 0:
|
||||
return f"{hours}h {minutes}min"
|
||||
return f"{minutes}min"
|
||||
|
||||
async def _run_executor(self, session_id: str) -> None:
|
||||
"""在独立 DB 会话中运行执行器(续行)。"""
|
||||
factory = _get_session_factory()
|
||||
async with factory() as db:
|
||||
svc = AutoSessionService(db, self.redis)
|
||||
executor = ActionExecutor(db, self.redis, audit=self.audit)
|
||||
try:
|
||||
await executor.run(session_id)
|
||||
await db.commit()
|
||||
except Exception as e: # noqa: BLE001
|
||||
await db.rollback()
|
||||
logger.error(f"续行执行失败 session={session_id}: {e}")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 场景配置管理(管理端)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
快照管理服务 — P3 核心组件。
|
||||
|
||||
在每次更正发生前创建信息项快照,支持更正撤销(undo)。
|
||||
撤销限制:最多撤销最近5次。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.automation import InformationItem, InformationSnapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_UNDO_COUNT = getattr(settings, "max_undo_count", 5)
|
||||
|
||||
|
||||
class SnapshotService:
|
||||
"""信息项快照管理服务。"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def create_snapshot(
|
||||
self,
|
||||
session_id: str,
|
||||
trigger_item_key: str,
|
||||
correction_ids: List[str],
|
||||
) -> InformationSnapshot:
|
||||
"""在更正前创建快照,记录当前全部信息项状态。
|
||||
|
||||
Args:
|
||||
session_id: 会话ID
|
||||
trigger_item_key: 触发更正的信息项 key
|
||||
correction_ids: 本次更正涉及的信息项 ID 列表
|
||||
Returns:
|
||||
InformationSnapshot: 创建的快照记录
|
||||
"""
|
||||
# 获取当前所有信息项
|
||||
items = await self._get_items(session_id)
|
||||
|
||||
# 构建快照数据
|
||||
snapshot_data = {}
|
||||
for item in items:
|
||||
snapshot_data[item.name] = {
|
||||
"value": item.value,
|
||||
"version": item.version,
|
||||
"id": item.id,
|
||||
}
|
||||
|
||||
snapshot = InformationSnapshot(
|
||||
session_id=session_id,
|
||||
trigger_item_key=trigger_item_key,
|
||||
snapshot_data=snapshot_data,
|
||||
correction_ids=correction_ids,
|
||||
is_undone=False,
|
||||
)
|
||||
self.db.add(snapshot)
|
||||
await self.db.flush()
|
||||
|
||||
logger.info(
|
||||
f"创建快照: session={session_id} trigger={trigger_item_key} "
|
||||
f"items={len(snapshot_data)}"
|
||||
)
|
||||
return snapshot
|
||||
|
||||
async def undo_correction(self, session_id: str) -> dict:
|
||||
"""撤销最近一次更正。
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"undone_items": List[str], # 被回滚的信息项名称
|
||||
"restored_values": Dict[str, str], # 恢复的值
|
||||
"snapshot_id": int,
|
||||
}
|
||||
Raises:
|
||||
ValueError: 无可撤销快照 或 撤销次数超限
|
||||
"""
|
||||
# 检查撤销次数
|
||||
undone_count = await self._count_undone(session_id)
|
||||
if undone_count >= MAX_UNDO_COUNT:
|
||||
raise ValueError(
|
||||
f"撤销次数超限,最多可撤销{MAX_UNDO_COUNT}次更正"
|
||||
)
|
||||
|
||||
# 获取最近一条未撤销的快照
|
||||
snapshot = await self.get_latest_snapshot(session_id)
|
||||
if snapshot is None:
|
||||
raise ValueError("无可撤销的更正")
|
||||
|
||||
# 回滚信息项
|
||||
undone_items = []
|
||||
restored_values = {}
|
||||
for item_name, item_data in snapshot.snapshot_data.items():
|
||||
item = await self._get_item(session_id, item_name)
|
||||
if item is not None:
|
||||
old_value = item.value
|
||||
item.value = item_data["value"]
|
||||
item.version = item_data["version"]
|
||||
undone_items.append(item_name)
|
||||
restored_values[item_name] = item_data["value"]
|
||||
|
||||
logger.info(
|
||||
f"撤销回滚: session={session_id} item={item_name} "
|
||||
f"value={old_value}->{item.value}"
|
||||
)
|
||||
|
||||
# 标记快照为已撤销
|
||||
snapshot.is_undone = True
|
||||
await self.db.flush()
|
||||
|
||||
logger.info(
|
||||
f"撤销完成: session={session_id} snapshot={snapshot.id} "
|
||||
f"items={undone_items}"
|
||||
)
|
||||
|
||||
return {
|
||||
"undone_items": undone_items,
|
||||
"restored_values": restored_values,
|
||||
"snapshot_id": snapshot.id,
|
||||
}
|
||||
|
||||
async def get_latest_snapshot(
|
||||
self, session_id: str
|
||||
) -> Optional[InformationSnapshot]:
|
||||
"""获取最近一条未撤销的快照。"""
|
||||
stmt = (
|
||||
select(InformationSnapshot)
|
||||
.where(
|
||||
InformationSnapshot.session_id == session_id,
|
||||
InformationSnapshot.is_undone == False, # noqa: E712
|
||||
)
|
||||
.order_by(InformationSnapshot.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def get_snapshot_history(
|
||||
self, session_id: str
|
||||
) -> List[InformationSnapshot]:
|
||||
"""获取快照历史列表。"""
|
||||
stmt = (
|
||||
select(InformationSnapshot)
|
||||
.where(InformationSnapshot.session_id == session_id)
|
||||
.order_by(InformationSnapshot.created_at.desc())
|
||||
)
|
||||
return list((await self.db.execute(stmt)).scalars().all())
|
||||
|
||||
async def get_version_diff(
|
||||
self, session_id: str, item_name: str, v1: int, v2: int
|
||||
) -> dict:
|
||||
"""对比某个信息项的两个版本。
|
||||
|
||||
从 update_history 中提取指定版本号的值进行对比。
|
||||
"""
|
||||
item = await self._get_item(session_id, item_name)
|
||||
if item is None:
|
||||
raise ValueError(f"信息项 {item_name} 不存在")
|
||||
|
||||
# 从 update_history 中找到对应版本
|
||||
history = {h["version"]: h for h in (item.update_history or [])}
|
||||
|
||||
v1_data = history.get(v1, {})
|
||||
v2_data = history.get(v2, {})
|
||||
|
||||
v1_value = v1_data.get("new_value", item.value if v1 == item.version else "")
|
||||
v2_value = v2_data.get("new_value", item.value if v2 == item.version else "")
|
||||
|
||||
return {
|
||||
"item_key": item_name,
|
||||
"v1": v1,
|
||||
"v1_value": v1_value,
|
||||
"v2": v2,
|
||||
"v2_value": v2_value,
|
||||
"changed": v1_value != v2_value,
|
||||
}
|
||||
|
||||
async def _get_items(self, session_id: str) -> List[InformationItem]:
|
||||
"""获取会话下所有信息项。"""
|
||||
stmt = select(InformationItem).where(
|
||||
InformationItem.session_id == session_id
|
||||
)
|
||||
return list((await self.db.execute(stmt)).scalars().all())
|
||||
|
||||
async def _get_item(
|
||||
self, session_id: str, name: str
|
||||
) -> Optional[InformationItem]:
|
||||
"""按名称获取单个信息项。"""
|
||||
stmt = select(InformationItem).where(
|
||||
InformationItem.session_id == session_id,
|
||||
InformationItem.name == name,
|
||||
)
|
||||
return (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def _count_undone(self, session_id: str) -> int:
|
||||
"""统计已撤销的快照数量。"""
|
||||
stmt = (
|
||||
select(func.count(InformationSnapshot.id))
|
||||
.where(
|
||||
InformationSnapshot.session_id == session_id,
|
||||
InformationSnapshot.is_undone == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalar() or 0
|
||||
@@ -0,0 +1,129 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 复杂场景重构 暂停超时清理定时任务
|
||||
# =============================================================================
|
||||
# 说明:后台定时任务,扫描 paused 状态且超过 24 小时未恢复的会话,
|
||||
# 自动标记为 closed(closed_by = "system(timeout)"),
|
||||
# 并清理 Redis 恢复点与暂停会话集合,推送超时关闭 WS 事件。
|
||||
#
|
||||
# 调用方式:
|
||||
# 1. FastAPI lifespan 中 asyncio.create_task(TimeoutCleaner(...).run_scheduled())
|
||||
# 2. 或外部调度器定期调用 run_once()
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.constants import (
|
||||
PAUSE_TIMEOUT_HOURS,
|
||||
REDIS_KEY_PAUSED_SESSIONS,
|
||||
REDIS_KEY_RESUME_POINT,
|
||||
)
|
||||
from app.models.automation import AutoSession
|
||||
from app.services.automation.progress_publisher import publish_timeout_closed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TimeoutCleaner:
|
||||
"""暂停超时清理器。
|
||||
|
||||
扫描 paused 状态的会话,超过 PAUSE_TIMEOUT_HOURS(默认 24 小时)
|
||||
未恢复的会话自动关闭,并推送 WS 通知。
|
||||
"""
|
||||
|
||||
def __init__(self, db_factory: Any, redis: Any = None):
|
||||
"""初始化。
|
||||
|
||||
Args:
|
||||
db_factory: 异步 DB 会话工厂(如 app.database._get_session_factory())
|
||||
redis: Redis 客户端(可选,用于清理恢复点)
|
||||
"""
|
||||
self.db_factory = db_factory
|
||||
self.redis = redis
|
||||
|
||||
async def run_once(self) -> int:
|
||||
"""执行一次扫描,返回关闭的会话数。
|
||||
|
||||
Returns:
|
||||
int: 本次扫描关闭的会话数量
|
||||
"""
|
||||
closed_count = 0
|
||||
async with self.db_factory() as db:
|
||||
# 查询超时的 paused 会话(paused_at < cutoff 隐含 NOT NULL)
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=PAUSE_TIMEOUT_HOURS)
|
||||
stmt = select(AutoSession).where(
|
||||
AutoSession.status == "paused",
|
||||
AutoSession.paused_at < cutoff,
|
||||
)
|
||||
sessions = list((await db.execute(stmt)).scalars().all())
|
||||
|
||||
if not sessions:
|
||||
return 0
|
||||
|
||||
for session in sessions:
|
||||
try:
|
||||
# 标记关闭
|
||||
session.status = "closed"
|
||||
session.closed_by = "system(timeout)"
|
||||
closed_at = datetime.now(timezone.utc)
|
||||
|
||||
# 清理 Redis 恢复点
|
||||
if self.redis:
|
||||
await self._cleanup_redis(session.id, session.employee_id)
|
||||
|
||||
await db.flush()
|
||||
closed_count += 1
|
||||
|
||||
# 推送 WS 超时关闭事件
|
||||
await publish_timeout_closed(
|
||||
session_id=session.id,
|
||||
closed_at=closed_at.isoformat(),
|
||||
reason=f"暂停超过 {PAUSE_TIMEOUT_HOURS} 小时未恢复,已自动关闭",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"超时关闭会话: session={session.id} "
|
||||
f"paused_at={session.paused_at} closed_at={closed_at}"
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error(f"超时关闭会话失败 session={session.id}: {e}")
|
||||
|
||||
await db.commit()
|
||||
|
||||
if closed_count > 0:
|
||||
logger.info(f"超时清理完成: 共关闭 {closed_count} 个暂停会话")
|
||||
return closed_count
|
||||
|
||||
async def run_scheduled(self, interval: int = 3600) -> None:
|
||||
"""定时扫描(每小时一次)。
|
||||
|
||||
Args:
|
||||
interval: 扫描间隔(秒),默认 3600 = 1 小时
|
||||
"""
|
||||
logger.info(f"启动暂停超时清理定时任务,间隔 {interval} 秒")
|
||||
while True:
|
||||
try:
|
||||
await self.run_once()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error(f"超时清理定时任务异常: {e}")
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
async def _cleanup_redis(self, session_id: str, employee_id: str) -> None:
|
||||
"""清理 Redis 中的恢复点和暂停会话集合。"""
|
||||
if not self.redis:
|
||||
return
|
||||
try:
|
||||
# 删除恢复点
|
||||
resume_key = REDIS_KEY_RESUME_POINT.format(session_id=session_id)
|
||||
await self.redis.delete(resume_key)
|
||||
# 从暂停会话集合中移除
|
||||
paused_key = REDIS_KEY_PAUSED_SESSIONS.format(employee_id=employee_id)
|
||||
await self.redis.srem(paused_key, session_id)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"Redis 清理失败 session={session_id}: {e}")
|
||||
@@ -39,9 +39,35 @@ def clean_avatar_url(url: str) -> str:
|
||||
"""
|
||||
if not url:
|
||||
return ""
|
||||
# 确保 HTTPS(企微头像可能返回 http://,HTTPS 页面不允许加载)
|
||||
if url.startswith("http://"):
|
||||
url = "https://" + url[7:]
|
||||
return url.split("?", 1)[0]
|
||||
|
||||
|
||||
def wrap_avatar_url(url: str) -> str:
|
||||
"""将企微头像 URL 包装为后端代理 URL,解决 COEP/Mixed Content/CSP 跨域问题。
|
||||
|
||||
如果 URL 包含 wework.qpic.cn,转换为 /api/avatar/proxy?url=... 格式。
|
||||
其他 URL(如 /duckula.webp)原样返回。
|
||||
|
||||
Args:
|
||||
url: 清理后的头像 URL
|
||||
|
||||
Returns:
|
||||
str: 包装后的代理 URL 或原始 URL
|
||||
"""
|
||||
if not url:
|
||||
return ""
|
||||
if "wework.qpic.cn" in url:
|
||||
# 确保 HTTPS(代理端点也会校验,这里做一次以防万一)
|
||||
if url.startswith("http://"):
|
||||
url = "https://" + url[7:]
|
||||
from urllib.parse import quote
|
||||
return f"/api/avatar/proxy?url={quote(url, safe='')}"
|
||||
return url
|
||||
|
||||
|
||||
async def sync_employee_avatar(
|
||||
db: AsyncSession,
|
||||
redis_client: Optional[object],
|
||||
|
||||
@@ -61,11 +61,30 @@ async def get_org_directory(
|
||||
wecom = WecomService(redis_client=redis)
|
||||
try:
|
||||
members = await wecom.get_department_members(1, 1)
|
||||
|
||||
# 获取部门列表,构建 {部门ID: 部门名称} 映射,用于将成员的 department ID 列表
|
||||
# 转换为可读的部门名称(企微 user/list 返回的 department 字段是 ID 列表如 [1,2])
|
||||
dept_map: Dict[int, str] = {}
|
||||
try:
|
||||
departments = await wecom.get_department_list()
|
||||
dept_map = {
|
||||
dept.get("id"): dept.get("name", "")
|
||||
for dept in departments
|
||||
if dept.get("id") is not None
|
||||
}
|
||||
logger.info(f"部门列表获取成功,共 {len(dept_map)} 个部门")
|
||||
except Exception as e:
|
||||
# 获取部门列表失败(权限不足等)时降级:使用原来的 ID 字符串,不阻塞主流程
|
||||
logger.warning(f"获取部门列表失败,降级使用部门ID字符串: {e}")
|
||||
|
||||
directory = [
|
||||
{
|
||||
"employee_id": m.get("userid", ""),
|
||||
"name": m.get("name", "") or "",
|
||||
"department": ",".join(str(d) for d in (m.get("department") or [])),
|
||||
# 优先用部门名映射,映射不到时降级为 ID 字符串
|
||||
"department": ",".join(
|
||||
dept_map.get(d, str(d)) for d in (m.get("department") or [])
|
||||
),
|
||||
}
|
||||
for m in members
|
||||
if m.get("userid")
|
||||
@@ -90,14 +109,36 @@ async def get_org_directory(
|
||||
logger.warning(f"企微通讯录获取失败,降级本地: {err_text}")
|
||||
|
||||
# 3. 降级:本地 employees 表(仅登录过的员工)
|
||||
# 注意:department 字段存储的是部门ID JSON数组(如 "[1,2]"),降级时无法解析为部门名;
|
||||
# 但在 DEV_MODE 下可直接存部门名(如 "研发一部"),使组织架构树在本地开发时也有数据
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(Employee.employee_id, Employee.name).where(Employee.employee_id != "")
|
||||
select(Employee.employee_id, Employee.name, Employee.department).where(Employee.employee_id != "")
|
||||
)
|
||||
rows = result.all()
|
||||
directory = [
|
||||
{"employee_id": r[0], "name": r[1] or "", "department": ""} for r in rows
|
||||
]
|
||||
directory = []
|
||||
for r in rows:
|
||||
dept_raw = r[2] or ""
|
||||
# 尝试解析 JSON 数组格式(生产环境企微返回的是部门ID列表如 "[1,2]")
|
||||
# 如果不是 JSON 格式(DEV_MODE 下直接存部门名),则原样使用
|
||||
dept_name = ""
|
||||
if dept_raw:
|
||||
try:
|
||||
import json as _json
|
||||
parsed = _json.loads(dept_raw)
|
||||
if isinstance(parsed, list) and parsed:
|
||||
# 部门ID列表:取第一个ID(降级时无法解析ID为名称,留空)
|
||||
dept_name = ""
|
||||
else:
|
||||
dept_name = str(parsed)
|
||||
except (ValueError, TypeError):
|
||||
# 不是 JSON 格式,直接作为部门名使用(DEV_MODE 场景)
|
||||
dept_name = dept_raw
|
||||
directory.append({
|
||||
"employee_id": r[0],
|
||||
"name": r[1] or "",
|
||||
"department": dept_name,
|
||||
})
|
||||
logger.info(f"组织目录降级到本地 employees 表,共 {len(directory)} 人")
|
||||
return directory, False
|
||||
except Exception as e:
|
||||
|
||||
@@ -29,7 +29,7 @@ class FunnyPhraseService:
|
||||
|
||||
# 默认话术(当数据库未配置时使用,和 PRD 一致)
|
||||
DEFAULT_PHRASES = {
|
||||
"shake": "大哥,俺这就去摇人,稍等...",
|
||||
"shake": "少主,这就为您去摇人,稍等...",
|
||||
"keyword": "收到!这就帮您摇位大神来",
|
||||
"waiting": "人还在路上,别急别急~",
|
||||
"connected": "人摇来了!IT坐席为您服务",
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — ITSM 运维平台待办数据源
|
||||
# =============================================================================
|
||||
# 说明:ITSM 一站式运维平台 OpenAPI 数据源实现。
|
||||
# 通过 ITSMSigner 进行 SHA1 签名认证,调用 ITSM OpenAPI 获取工单待办。
|
||||
#
|
||||
# 当前状态:
|
||||
# - get_todo_list(): ITSM 列表 API 尚未提供,返回空列表 + 日志告警
|
||||
# - get_todo_detail(): 已实现工单详情查询(调 workitem/detail API)
|
||||
#
|
||||
# 待 ITSM 列表 API 到位后,只需实现 get_todo_list() 内部逻辑即可。
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.config import settings
|
||||
from app.services.todo_source_service import TodoSourceService
|
||||
from app.utils.itsm_signer import ITSMSigner
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ITSM OpenAPI 工单详情端点
|
||||
ITSM_WORKITEM_DETAIL_PATH = "/openapi/v1/process/workitem/detail"
|
||||
|
||||
# ITSM API 成功响应码
|
||||
ITSM_SUCCESS_CODE = 20000
|
||||
|
||||
# ITSM HTTP 请求超时
|
||||
ITSM_TIMEOUT = httpx.Timeout(timeout=30.0, connect=10.0, read=30.0)
|
||||
|
||||
|
||||
def _itsm_priority_to_todo(itsm_priority: Any) -> str:
|
||||
"""将 ITSM 优先级映射到统一的 todo 优先级(urgent/high/normal)。
|
||||
|
||||
Args:
|
||||
itsm_priority: ITSM 返回的优先级字段(可能是字符串或数字)
|
||||
|
||||
Returns:
|
||||
str: 统一优先级 urgent/high/normal
|
||||
"""
|
||||
if itsm_priority is None:
|
||||
return "normal"
|
||||
priority_str = str(itsm_priority).strip().lower()
|
||||
# 紧急 / urgent / 1
|
||||
if priority_str in ("urgent", "紧急", "1", "critical", "p0", "p1"):
|
||||
return "urgent"
|
||||
# 高 / high / 2
|
||||
if priority_str in ("high", "高", "2", "p2"):
|
||||
return "high"
|
||||
return "normal"
|
||||
|
||||
|
||||
def _itsm_status_to_todo(itsm_status: Any) -> str:
|
||||
"""将 ITSM 状态映射到统一的 todo 状态(pending/processing/resolved)。
|
||||
|
||||
Args:
|
||||
itsm_status: ITSM 返回的状态字段
|
||||
|
||||
Returns:
|
||||
str: 统一状态 pending/processing/resolved
|
||||
"""
|
||||
if itsm_status is None:
|
||||
return "pending"
|
||||
status_str = str(itsm_status).strip().lower()
|
||||
# 已完成/已关闭类
|
||||
if status_str in ("resolved", "closed", "done", "完成", "已关闭", "已完成", "resolved", "3"):
|
||||
return "resolved"
|
||||
# 处理中
|
||||
if status_str in ("processing", "in_progress", "处理中", "2"):
|
||||
return "processing"
|
||||
# 默认待处理
|
||||
return "pending"
|
||||
|
||||
|
||||
class ITSMService(TodoSourceService):
|
||||
"""ITSM 运维平台待办数据源实现。
|
||||
|
||||
通过 ITSM OpenAPI 获取当前坐席的代办工单。
|
||||
签名认证使用 ITSMSigner(SHA1 签名),请求头携带 appId/timestamp/sign。
|
||||
|
||||
Attributes:
|
||||
agent_userid: 当前坐席的企微 userid
|
||||
redis: Redis 异步客户端(预留,后续 SSO 认证可能需要)
|
||||
base_url: ITSM API 基址
|
||||
app_id: ITSM OpenAPI app_id
|
||||
app_secret: ITSM OpenAPI app_secret
|
||||
"""
|
||||
|
||||
def __init__(self, agent_userid: str, redis: aioredis.Redis):
|
||||
"""初始化 ITSM 数据源服务。
|
||||
|
||||
从 settings 读取 ITSM 配置。如果 itsm_app_id 为空,
|
||||
所有方法将返回空结果并记日志告警。
|
||||
|
||||
Args:
|
||||
agent_userid: 当前坐席的企微 userid
|
||||
redis: Redis 异步客户端实例
|
||||
"""
|
||||
self.agent_userid = agent_userid
|
||||
self.redis = redis
|
||||
self.base_url = settings.itsm_base_url.rstrip("/")
|
||||
self.app_id = settings.itsm_app_id
|
||||
self.app_secret = settings.itsm_app_secret
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 公开接口
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_todo_list(self) -> List[Dict[str, Any]]:
|
||||
"""获取 ITSM 代办工单列表。
|
||||
|
||||
⚠️ ITSM 列表 API 尚未提供,当前返回空列表 + 日志告警。
|
||||
待 API 到位后实现列表查询逻辑。
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 空列表(API 待实现)
|
||||
"""
|
||||
if not self.app_id:
|
||||
logger.warning("ITSM app_id 未配置,代办列表返回空")
|
||||
return []
|
||||
|
||||
logger.warning(
|
||||
"ITSM 代办列表 API 尚未实现,返回空列表。"
|
||||
"待 ITSM 平台方提供列表 API 端点后补充实现。"
|
||||
)
|
||||
return []
|
||||
|
||||
async def get_todo_detail(self, item_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取 ITSM 工单详情。
|
||||
|
||||
通过 ITSM OpenAPI workitem/detail 端点获取工单详情,
|
||||
并映射为统一 TodoItemData 格式。
|
||||
|
||||
Args:
|
||||
item_id: 工单的 process_instance_id(不含 "ticket:" 前缀)
|
||||
|
||||
Returns:
|
||||
Optional[Dict[str, Any]]: TodoItemData 格式的工单详情
|
||||
"""
|
||||
if not self.app_id:
|
||||
logger.warning("ITSM app_id 未配置,无法查询工单详情")
|
||||
return None
|
||||
|
||||
try:
|
||||
# 调用 ITSM workitem detail API
|
||||
detail = await self._get_workitem_detail(item_id, self.agent_userid)
|
||||
if not detail:
|
||||
return None
|
||||
|
||||
return self._map_to_todo_item(detail)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"获取 ITSM 工单详情失败: item_id={item_id}, error={e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 私有方法
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _do_post(self, url: str, body: Dict[str, Any]) -> Optional[dict]:
|
||||
"""发送带签名的 POST 请求到 ITSM API。
|
||||
|
||||
使用 ITSMSigner 生成签名请求头,发送 JSON POST 请求。
|
||||
解析 ITSM 标准响应格式 {code: 20000, data: {...}, message: "..."}。
|
||||
|
||||
Args:
|
||||
url: 完整的 ITSM API URL
|
||||
body: 请求体(业务数据)
|
||||
|
||||
Returns:
|
||||
Optional[dict]: ITSM 响应中的 data 字段,失败返回 None
|
||||
"""
|
||||
headers = ITSMSigner.get_headers(self.app_id, self.app_secret, body)
|
||||
|
||||
async with httpx.AsyncClient(timeout=ITSM_TIMEOUT) as client:
|
||||
response = await client.post(url, json=body, headers=headers)
|
||||
result = response.json()
|
||||
|
||||
code = result.get("code")
|
||||
if code != ITSM_SUCCESS_CODE:
|
||||
logger.error(
|
||||
f"ITSM API 调用失败: url={url}, code={code}, "
|
||||
f"message={result.get('message', '')}"
|
||||
)
|
||||
return None
|
||||
|
||||
return result.get("data")
|
||||
|
||||
async def _get_workitem_detail(
|
||||
self, process_instance_id: str, executor: str
|
||||
) -> Optional[dict]:
|
||||
"""调用 ITSM 工单详情 API。
|
||||
|
||||
POST {base_url}/openapi/v1/process/workitem/detail
|
||||
|
||||
Args:
|
||||
process_instance_id: 工单流程实例 ID
|
||||
executor: 当前执行人(坐席 userid)
|
||||
|
||||
Returns:
|
||||
Optional[dict]: ITSM 返回的工单详情数据
|
||||
"""
|
||||
url = f"{self.base_url}{ITSM_WORKITEM_DETAIL_PATH}"
|
||||
body = {
|
||||
"process_instance_id": process_instance_id,
|
||||
"executor": executor,
|
||||
}
|
||||
return await self._do_post(url, body)
|
||||
|
||||
def _map_to_todo_item(self, detail: dict) -> Dict[str, Any]:
|
||||
"""将 ITSM 工单详情映射为统一 TodoItemData 格式。
|
||||
|
||||
映射规则参考系统设计文档 §8.2:
|
||||
- id: "ticket:{process_instance_id}"
|
||||
- type: "ticket"
|
||||
- title: 工单标题
|
||||
- priority: ITSM 优先级映射到 urgent/high/normal
|
||||
- status: ITSM 状态映射到 pending/processing/resolved
|
||||
|
||||
Args:
|
||||
detail: ITSM API 返回的工单详情
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: TodoItemData 格式的待办事项
|
||||
"""
|
||||
process_instance_id = detail.get("process_instance_id", "")
|
||||
title = detail.get("title", "")
|
||||
itsm_priority = detail.get("priority", "normal")
|
||||
itsm_status = detail.get("status", "pending")
|
||||
creator = detail.get("creator", "")
|
||||
executor = detail.get("executor", "")
|
||||
created_at = detail.get("created_at", "")
|
||||
updated_at = detail.get("updated_at", "")
|
||||
|
||||
return {
|
||||
"id": f"ticket:{process_instance_id}",
|
||||
"type": "ticket",
|
||||
"title": title or "ITSM 工单",
|
||||
"priority": _itsm_priority_to_todo(itsm_priority),
|
||||
"description": {
|
||||
"process_instance_id": process_instance_id,
|
||||
"executor": executor,
|
||||
"status": itsm_status,
|
||||
"creator": creator,
|
||||
"itsm_priority": itsm_priority,
|
||||
"title": title,
|
||||
},
|
||||
"status": _itsm_status_to_todo(itsm_status),
|
||||
"assigned_agent_id": self.agent_userid,
|
||||
"corp_id": "",
|
||||
"created_at": created_at,
|
||||
"updated_at": updated_at,
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 会议室预定业务服务
|
||||
# =============================================================================
|
||||
# 说明:作为企微会议室API的薄代理层,提供:
|
||||
# 1. Redis缓存管理(减少企微API调用)
|
||||
# 2. 企微API代理(列表/预定状态/预定/取消/详情)
|
||||
# 3. 实时状态计算(综合判断空闲/使用中/即将开始)
|
||||
# 4. 状态变更后WebSocket通知终端
|
||||
#
|
||||
# 设计决策:
|
||||
# - 企微API为唯一数据源,不在本地维护预定数据
|
||||
# - 读操作加Redis短缓存(30秒),写操作直接转发企微API
|
||||
# - 预定/取消成功后立即清除缓存并推送WS通知
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.services.wecom_service import WecomService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MeetingroomService:
|
||||
"""会议室预定业务服务 — 企微API代理 + Redis缓存。
|
||||
|
||||
所有预定数据以企微API为唯一数据源,本服务仅做代理+缓存+鉴权。
|
||||
terminal_room_binding 表仅存终端↔会议室映射关系,不存预定数据。
|
||||
"""
|
||||
|
||||
# Redis 缓存 key 前缀与 TTL
|
||||
CACHE_KEY_ROOM_LIST = "meetingroom:room_list" # 会议室列表 (TTL=600s)
|
||||
CACHE_KEY_BOOKING_INFO = "meetingroom:booking:{room_id}:{date}" # 预定状态 (TTL=30s)
|
||||
CACHE_KEY_BOOKING_DETAIL = "meetingroom:detail:{booking_id}" # 预定详情 (TTL=300s)
|
||||
CACHE_KEY_STATUS = "meetingroom:status:{room_id}" # 实时状态 (TTL=10s)
|
||||
|
||||
# 缓存 TTL(秒)
|
||||
TTL_ROOM_LIST = 600
|
||||
TTL_BOOKING_INFO = 30
|
||||
TTL_BOOKING_DETAIL = 300
|
||||
TTL_STATUS = 10
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
wecom_service: WecomService,
|
||||
redis_client: Optional[aioredis.Redis] = None,
|
||||
) -> None:
|
||||
"""初始化会议室预定服务。
|
||||
|
||||
Args:
|
||||
wecom_service: 企微API服务实例
|
||||
redis_client: Redis 异步客户端(可为 None)
|
||||
"""
|
||||
self.wecom = wecom_service
|
||||
self.redis = redis_client
|
||||
|
||||
# ==========================================================================
|
||||
# 会议室列表
|
||||
# ==========================================================================
|
||||
|
||||
async def get_room_list(
|
||||
self,
|
||||
city: Optional[str] = None,
|
||||
building: Optional[str] = None,
|
||||
floor: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取会议室列表(缓存10分钟)。
|
||||
|
||||
代理调用企微API获取会议室列表,结果缓存到Redis。
|
||||
|
||||
Args:
|
||||
city: 城市名称(可选过滤)
|
||||
building: 楼宇名称(可选过滤)
|
||||
floor: 楼层名称(可选过滤)
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 会议室列表
|
||||
"""
|
||||
# 构建缓存key(含过滤条件)
|
||||
filter_parts = []
|
||||
if city:
|
||||
filter_parts.append(f"city={city}")
|
||||
if building:
|
||||
filter_parts.append(f"building={building}")
|
||||
if floor:
|
||||
filter_parts.append(f"floor={floor}")
|
||||
filter_str = ":".join(filter_parts) if filter_parts else "all"
|
||||
cache_key = f"{self.CACHE_KEY_ROOM_LIST}:{filter_str}"
|
||||
|
||||
# 1. 尝试从缓存获取
|
||||
if self.redis:
|
||||
try:
|
||||
cached = await self.redis.get(cache_key)
|
||||
if cached:
|
||||
logger.debug(f"从缓存获取会议室列表: filter={filter_str}")
|
||||
return json.loads(cached)
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 读取会议室列表缓存失败: {e}")
|
||||
|
||||
# 2. 调用企微API
|
||||
try:
|
||||
room_list = await self.wecom.get_meetingroom_list(city, building, floor)
|
||||
except Exception as e:
|
||||
logger.error(f"获取会议室列表失败: {e}")
|
||||
raise
|
||||
|
||||
# 3. 缓存到Redis
|
||||
if self.redis:
|
||||
try:
|
||||
await self.redis.setex(cache_key, self.TTL_ROOM_LIST, json.dumps(room_list, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 写入会议室列表缓存失败: {e}")
|
||||
|
||||
return room_list
|
||||
|
||||
# ==========================================================================
|
||||
# 预定状态查询
|
||||
# ==========================================================================
|
||||
|
||||
async def get_room_status(
|
||||
self,
|
||||
meetingroom_id: int,
|
||||
date: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取指定日期的预定状态(缓存30秒)。
|
||||
|
||||
Args:
|
||||
meetingroom_id: 企微会议室ID
|
||||
date: 查询日期(YYYY-MM-DD格式,默认今天)
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 预定记录列表
|
||||
"""
|
||||
# 默认今天
|
||||
if not date:
|
||||
date = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
cache_key = self.CACHE_KEY_BOOKING_INFO.format(room_id=meetingroom_id, date=date)
|
||||
|
||||
# 1. 尝试从缓存获取
|
||||
if self.redis:
|
||||
try:
|
||||
cached = await self.redis.get(cache_key)
|
||||
if cached:
|
||||
logger.debug(f"从缓存获取预定状态: room_id={meetingroom_id}, date={date}")
|
||||
return json.loads(cached)
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 读取预定状态缓存失败: {e}")
|
||||
|
||||
# 2. 构建查询时间范围(当天 00:00 ~ 23:59,带时区)
|
||||
from app.config import settings
|
||||
tz_offset = "+08:00"
|
||||
start_time = f"{date}T00:00:00{tz_offset}"
|
||||
end_time = f"{date}T23:59:59{tz_offset}"
|
||||
|
||||
# 3. 调用企微API
|
||||
try:
|
||||
booking_list = await self.wecom.get_booking_info(meetingroom_id, start_time, end_time)
|
||||
except Exception as e:
|
||||
logger.error(f"获取预定状态失败: room_id={meetingroom_id}, date={date}, error={e}")
|
||||
raise
|
||||
|
||||
# 4. 缓存到Redis
|
||||
if self.redis:
|
||||
try:
|
||||
await self.redis.setex(cache_key, self.TTL_BOOKING_INFO, json.dumps(booking_list, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 写入预定状态缓存失败: {e}")
|
||||
|
||||
return booking_list
|
||||
|
||||
# ==========================================================================
|
||||
# 实时状态计算
|
||||
# ==========================================================================
|
||||
|
||||
async def get_current_status(self, meetingroom_id: int) -> Dict[str, Any]:
|
||||
"""获取当前实时状态(综合判断空闲/使用中/即将开始)。
|
||||
|
||||
缓存10秒(极短缓存防刷),过期后重新计算。
|
||||
|
||||
状态判断逻辑:
|
||||
- busy: 当前时间在某个预定的 start_time ~ end_time 之间
|
||||
- starting_soon: 当前时间距下一个预定开始时间 ≤ 15分钟
|
||||
- free: 其他情况
|
||||
|
||||
Args:
|
||||
meetingroom_id: 企微会议室ID
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 包含 status/current_meeting/next_meeting/minutes_to_next/bookings
|
||||
"""
|
||||
cache_key = self.CACHE_KEY_STATUS.format(room_id=meetingroom_id)
|
||||
|
||||
# 1. 尝试从缓存获取
|
||||
if self.redis:
|
||||
try:
|
||||
cached = await self.redis.get(cache_key)
|
||||
if cached:
|
||||
logger.debug(f"从缓存获取实时状态: room_id={meetingroom_id}")
|
||||
return json.loads(cached)
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 读取实时状态缓存失败: {e}")
|
||||
|
||||
# 2. 获取当日预定列表
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
bookings = await self.get_room_status(meetingroom_id, today)
|
||||
|
||||
# 3. 计算当前状态
|
||||
now = datetime.now()
|
||||
current_meeting: Optional[Dict[str, Any]] = None
|
||||
next_meeting: Optional[Dict[str, Any]] = None
|
||||
status = "free"
|
||||
minutes_to_next: Optional[int] = None
|
||||
|
||||
# 遍历预定记录,查找当前进行中的会议和下一个会议
|
||||
active_bookings = []
|
||||
for booking in bookings:
|
||||
# 跳过已取消的预定
|
||||
if booking.get("status", 0) != 0:
|
||||
continue
|
||||
# 解析时间(企微返回的时间戳或ISO字符串)
|
||||
start_time = self._parse_booking_time(booking.get("start_time"))
|
||||
end_time = self._parse_booking_time(booking.get("end_time"))
|
||||
if not start_time or not end_time:
|
||||
continue
|
||||
|
||||
active_bookings.append(booking)
|
||||
|
||||
# 检查是否当前进行中
|
||||
if start_time <= now <= end_time:
|
||||
current_meeting = self._format_booking(booking)
|
||||
status = "busy"
|
||||
# 检查是否是下一个即将开始的
|
||||
elif start_time > now:
|
||||
if next_meeting is None or start_time < self._parse_booking_time(next_meeting.get("start_time")):
|
||||
next_meeting = self._format_booking(booking)
|
||||
minutes_to_next = int((start_time - now).total_seconds() / 60)
|
||||
|
||||
# 如果当前没有进行中的会议,但下一个会议在15分钟内开始
|
||||
if status == "free" and next_meeting and minutes_to_next is not None and minutes_to_next <= 15:
|
||||
status = "starting_soon"
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
"status": status,
|
||||
"current_meeting": current_meeting,
|
||||
"next_meeting": next_meeting,
|
||||
"minutes_to_next": minutes_to_next,
|
||||
"bookings": [self._format_booking(b) for b in active_bookings],
|
||||
}
|
||||
|
||||
# 4. 缓存到Redis(极短TTL)
|
||||
if self.redis:
|
||||
try:
|
||||
await self.redis.setex(cache_key, self.TTL_STATUS, json.dumps(result, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 写入实时状态缓存失败: {e}")
|
||||
|
||||
return result
|
||||
|
||||
# ==========================================================================
|
||||
# 预定操作
|
||||
# ==========================================================================
|
||||
|
||||
async def book_room(
|
||||
self,
|
||||
meetingroom_id: int,
|
||||
subject: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
booker: str,
|
||||
attendees: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""预定会议室(成功后清除该会议室的预定状态缓存)。
|
||||
|
||||
Args:
|
||||
meetingroom_id: 企微会议室ID
|
||||
subject: 会议主题
|
||||
start_time: 开始时间(ISO 8601)
|
||||
end_time: 结束时间(ISO 8601)
|
||||
booker: 预定人userid
|
||||
attendees: 参与人userid列表(可选)
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 预定结果,含 booking_id
|
||||
|
||||
Raises:
|
||||
Exception: 预定失败
|
||||
"""
|
||||
try:
|
||||
result = await self.wecom.book_meetingroom(
|
||||
meetingroom_id=meetingroom_id,
|
||||
subject=subject,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
booker=booker,
|
||||
attendees=attendees,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"预定会议室失败: room_id={meetingroom_id}, error={e}")
|
||||
raise
|
||||
|
||||
# 预定成功后清除缓存
|
||||
await self.invalidate_room_cache(meetingroom_id)
|
||||
|
||||
logger.info(f"预定成功: room_id={meetingroom_id}, booking_id={result.get('booking_id')}")
|
||||
return result
|
||||
|
||||
# ==========================================================================
|
||||
# 取消预定
|
||||
# ==========================================================================
|
||||
|
||||
async def cancel_booking(self, booking_id: str, meetingroom_id: int) -> Dict[str, Any]:
|
||||
"""取消预定(成功后清除缓存)。
|
||||
|
||||
Args:
|
||||
booking_id: 企微预定ID
|
||||
meetingroom_id: 企微会议室ID
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 取消结果
|
||||
|
||||
Raises:
|
||||
Exception: 取消失败
|
||||
"""
|
||||
try:
|
||||
result = await self.wecom.cancel_booking(booking_id, meetingroom_id)
|
||||
except Exception as e:
|
||||
logger.error(f"取消预定失败: booking_id={booking_id}, error={e}")
|
||||
raise
|
||||
|
||||
# 取消成功后清除缓存
|
||||
await self.invalidate_room_cache(meetingroom_id)
|
||||
|
||||
logger.info(f"取消成功: booking_id={booking_id}, room_id={meetingroom_id}")
|
||||
return result
|
||||
|
||||
# ==========================================================================
|
||||
# 预定详情
|
||||
# ==========================================================================
|
||||
|
||||
async def get_booking_detail(self, meetingroom_id: int, booking_id: str) -> Dict[str, Any]:
|
||||
"""获取预定详情(缓存5分钟)。
|
||||
|
||||
Args:
|
||||
meetingroom_id: 企微会议室ID
|
||||
booking_id: 企微预定ID
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 预定详情
|
||||
"""
|
||||
cache_key = self.CACHE_KEY_BOOKING_DETAIL.format(booking_id=booking_id)
|
||||
|
||||
# 1. 尝试从缓存获取
|
||||
if self.redis:
|
||||
try:
|
||||
cached = await self.redis.get(cache_key)
|
||||
if cached:
|
||||
logger.debug(f"从缓存获取预定详情: booking_id={booking_id}")
|
||||
return json.loads(cached)
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 读取预定详情缓存失败: {e}")
|
||||
|
||||
# 2. 调用企微API
|
||||
try:
|
||||
detail = await self.wecom.get_booking_detail(meetingroom_id, booking_id)
|
||||
except Exception as e:
|
||||
logger.error(f"获取预定详情失败: booking_id={booking_id}, error={e}")
|
||||
raise
|
||||
|
||||
# 3. 缓存到Redis
|
||||
if self.redis:
|
||||
try:
|
||||
await self.redis.setex(cache_key, self.TTL_BOOKING_DETAIL, json.dumps(detail, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 写入预定详情缓存失败: {e}")
|
||||
|
||||
return detail
|
||||
|
||||
# ==========================================================================
|
||||
# 缓存管理
|
||||
# ==========================================================================
|
||||
|
||||
async def invalidate_room_cache(
|
||||
self,
|
||||
meetingroom_id: int,
|
||||
date: Optional[str] = None,
|
||||
) -> None:
|
||||
"""清除指定会议室的缓存(预定/取消后调用)。
|
||||
|
||||
清除以下缓存:
|
||||
- meetingroom:booking:{room_id}:{date} — 预定状态
|
||||
- meetingroom:status:{room_id} — 实时状态
|
||||
|
||||
Args:
|
||||
meetingroom_id: 企微会议室ID
|
||||
date: 日期(默认今天)
|
||||
"""
|
||||
if not self.redis:
|
||||
return
|
||||
|
||||
if not date:
|
||||
date = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
keys_to_delete = [
|
||||
self.CACHE_KEY_BOOKING_INFO.format(room_id=meetingroom_id, date=date),
|
||||
self.CACHE_KEY_STATUS.format(room_id=meetingroom_id),
|
||||
]
|
||||
|
||||
try:
|
||||
for key in keys_to_delete:
|
||||
await self.redis.delete(key)
|
||||
logger.info(f"已清除会议室缓存: room_id={meetingroom_id}, date={date}")
|
||||
except Exception as e:
|
||||
logger.warning(f"清除会议室缓存失败: {e}")
|
||||
|
||||
# ==========================================================================
|
||||
# WebSocket 通知
|
||||
# ==========================================================================
|
||||
|
||||
async def notify_terminal_update(
|
||||
self,
|
||||
terminal_sn: str,
|
||||
meetingroom_id: int,
|
||||
) -> None:
|
||||
"""状态变更后通过WebSocket通知终端。
|
||||
|
||||
获取最新状态后,通过ConnectionManager推送给绑定的终端。
|
||||
|
||||
Args:
|
||||
terminal_sn: 终端序列号
|
||||
meetingroom_id: 企微会议室ID
|
||||
"""
|
||||
try:
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
|
||||
# 获取最新状态
|
||||
status_data = await self.get_current_status(meetingroom_id)
|
||||
|
||||
# 构建推送消息
|
||||
message = {
|
||||
"type": "room_status_update",
|
||||
"data": {
|
||||
"meetingroom_id": meetingroom_id,
|
||||
"status": status_data.get("status"),
|
||||
"current_meeting": status_data.get("current_meeting"),
|
||||
"next_meeting": status_data.get("next_meeting"),
|
||||
"minutes_to_next": status_data.get("minutes_to_next"),
|
||||
},
|
||||
}
|
||||
|
||||
# 推送给终端
|
||||
await ws_manager.send_to_terminal(terminal_sn, message)
|
||||
logger.info(f"已推送状态更新到终端: sn={terminal_sn}, room_id={meetingroom_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"推送终端状态更新失败: sn={terminal_sn}, error={e}")
|
||||
|
||||
# ==========================================================================
|
||||
# 辅助方法
|
||||
# ==========================================================================
|
||||
|
||||
@staticmethod
|
||||
def _parse_booking_time(time_value: Any) -> Optional[datetime]:
|
||||
"""解析预定时间(支持时间戳和ISO字符串两种格式)。
|
||||
|
||||
企微API返回的时间可能是:
|
||||
- 整数时间戳(秒)
|
||||
- ISO 8601 字符串
|
||||
|
||||
Args:
|
||||
time_value: 时间值
|
||||
|
||||
Returns:
|
||||
Optional[datetime]: 解析后的datetime对象
|
||||
"""
|
||||
if not time_value:
|
||||
return None
|
||||
try:
|
||||
if isinstance(time_value, (int, float)):
|
||||
return datetime.fromtimestamp(time_value)
|
||||
if isinstance(time_value, str):
|
||||
# 尝试解析ISO格式
|
||||
if "T" in time_value:
|
||||
return datetime.fromisoformat(time_value)
|
||||
# 尝试解析时间戳字符串
|
||||
return datetime.fromtimestamp(int(time_value))
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"解析预定时间失败: value={time_value}, error={e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _format_booking(booking: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""格式化预定记录,统一时间格式为ISO字符串。
|
||||
|
||||
Args:
|
||||
booking: 原始预定记录
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 格式化后的预定记录
|
||||
"""
|
||||
result = dict(booking)
|
||||
# 转换时间戳为ISO字符串
|
||||
for field in ("start_time", "end_time"):
|
||||
value = booking.get(field)
|
||||
dt = MeetingroomService._parse_booking_time(value)
|
||||
if dt:
|
||||
result[field] = dt.isoformat()
|
||||
return result
|
||||
@@ -523,7 +523,7 @@ class Neo4jClient:
|
||||
cypher = (
|
||||
f"MATCH (from_node), (to_node) "
|
||||
f"WHERE from_node.uuid = $from_uuid AND to_node.uuid = $to_uuid "
|
||||
f"CREATE (from_node)-[:{rel_type} {{order: $order, weight: $weight}}]->(to_node) "
|
||||
f"MERGE (from_node)-[:{rel_type} {{order: $order, weight: $weight}}]->(to_node) "
|
||||
f"RETURN count(*) AS created"
|
||||
)
|
||||
data = await self.execute_write_query(
|
||||
|
||||
@@ -11,18 +11,15 @@ from app.services.wecom_service import WecomService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 超时提醒消息内容
|
||||
REMINDER_MESSAGE = (
|
||||
"IT服务提醒:您有新的消息未查看,"
|
||||
"咨询将在10分钟后标记为待关闭,请尽快点击处理 👉 "
|
||||
"https://itsupport.servyou.com.cn/itdesk/"
|
||||
)
|
||||
# 跳转链接 - 员工端H5咨询页面
|
||||
REMINDER_URL = "https://itsupport.servyou.com.cn/h5/"
|
||||
|
||||
|
||||
async def send_reminder_message(employee_id: str) -> bool:
|
||||
"""发送超时提醒企微消息。
|
||||
"""发送超时提醒企微消息(模板卡片样式)。
|
||||
|
||||
当坐席回复后员工超过3分钟未回复时,发送企微消息提醒员工查看。
|
||||
当坐席回复后员工超过3分钟未回复时,发送企微模板卡片消息提醒员工查看。
|
||||
使用 text_notice 类型模板卡片,支持点击跳转按钮。
|
||||
|
||||
Args:
|
||||
employee_id: 员工的企微 UserID
|
||||
@@ -38,11 +35,38 @@ async def send_reminder_message(employee_id: str) -> bool:
|
||||
wecom_service = WecomService(redis_client)
|
||||
|
||||
try:
|
||||
result = await wecom_service.send_text_message(
|
||||
employee_id, REMINDER_MESSAGE
|
||||
# 使用模板卡片消息(text_notice 类型)- 参考企微最佳实践设计
|
||||
result = await wecom_service.send_template_card_message(
|
||||
user_id=employee_id,
|
||||
# 来源区域
|
||||
source_desc="IT智能服务台",
|
||||
# 主标题区域
|
||||
main_title="您的IT咨询即将关闭",
|
||||
main_title_desc="请尽快回复坐席,否则咨询将自动结束",
|
||||
# 副标题
|
||||
sub_title_text="点击下方按钮直接跳转到咨询页面",
|
||||
# 高亮区域
|
||||
emphasis_title="2分钟",
|
||||
emphasis_desc="剩余处理时间",
|
||||
# 关键信息列表
|
||||
horizontal_content_list=[
|
||||
{"keyname": "咨询内容", "value": "IT问题咨询"},
|
||||
{"keyname": "坐席状态", "value": "已回复"},
|
||||
],
|
||||
# 跳转按钮
|
||||
jump_list=[
|
||||
{
|
||||
"type": 1, # 跳转URL
|
||||
"title": "立即回复",
|
||||
"url": REMINDER_URL,
|
||||
}
|
||||
],
|
||||
# 卡片点击区域
|
||||
card_action_url=REMINDER_URL,
|
||||
)
|
||||
|
||||
if result.get("errcode") == 0:
|
||||
logger.info(f"超时提醒发送成功: employee_id={employee_id}")
|
||||
logger.info(f"超时提醒(模板卡片)发送成功: employee_id={employee_id}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 业务路由推荐服务
|
||||
# =============================================================================
|
||||
# 说明:核心路由逻辑,在 H5 后台 AI 任务中拦截非IT业务消息,
|
||||
# 调用 Dify 统一意图识别,判定业务类别后发送对应联系人名片卡片。
|
||||
#
|
||||
# 主要职责:
|
||||
# 1. 关键词预过滤(ROUTING_PREFILTER_KEYWORDS)— 快速过滤非路由消息
|
||||
# 2. Dify 统一意图识别 — 调用 /v1/chat-messages,解析 intent_type/business_category/routing_confidence
|
||||
# 3. 联系人查询 — 按 business_category 查 business_contacts 表
|
||||
# 4. 名片三段式发送 — 路由文本 → contact_card → 系统提示(WS双通道推送)
|
||||
# 5. 路由事件记录(P1)— 记录路由命中统计
|
||||
#
|
||||
# 设计决策:
|
||||
# - 路由检测放在后台任务而非前端调用,与 BYOD 卡片处理模式一致
|
||||
# - 关键词预过滤与审批预过滤可能重叠,Dify Prompt 内部判断优先级确保正确分流
|
||||
# - routing_confidence < 0.7 不触发名片推荐,走正常 AI 回复流程
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import settings
|
||||
from app.models.business_contact import BusinessContact
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.message import Message
|
||||
from app.models.routing_event import RoutingEvent
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 路由关键词预过滤列表
|
||||
# =============================================================================
|
||||
# 说明:覆盖 5 个业务类别的关键词,用于快速过滤非路由消息。
|
||||
# 只要命中任意一个关键词才值得调用 Dify 做精确判断。
|
||||
# 关键词可能与审批预过滤重叠(如"办公用品"),Dify Prompt 内部判断
|
||||
# 优先级(先审批→再IT咨询→再非IT路由)确保正确分流。
|
||||
|
||||
ROUTING_PREFILTER_KEYWORDS: list[str] = [
|
||||
# 行政
|
||||
"打印机", "复印机", "扫描仪", "保洁", "名片印刷",
|
||||
# 人力资源
|
||||
"工牌", "考勤", "入职", "离职", "社保", "公积金",
|
||||
# 财务
|
||||
"报销", "发票", "借款", "工资条",
|
||||
# 法务
|
||||
"合同", "法务", "知识产权",
|
||||
# 行政-物业
|
||||
"空调", "电梯", "门禁", "停车",
|
||||
]
|
||||
|
||||
# 关键词到业务类别的映射(Dify 不可用时降级兜底用)
|
||||
ROUTING_KEYWORD_TO_CATEGORY: dict[str, str] = {
|
||||
# 行政
|
||||
"打印机": "行政", "复印机": "行政", "扫描仪": "行政",
|
||||
"保洁": "行政", "名片印刷": "行政",
|
||||
# 人力资源
|
||||
"工牌": "人力资源", "考勤": "人力资源", "入职": "人力资源",
|
||||
"离职": "人力资源", "社保": "人力资源", "公积金": "人力资源",
|
||||
# 财务
|
||||
"报销": "财务", "发票": "财务", "借款": "财务", "工资条": "财务",
|
||||
# 法务
|
||||
"合同": "法务", "法务": "法务", "知识产权": "法务",
|
||||
# 行政-物业
|
||||
"空调": "行政-物业", "电梯": "行政-物业", "门禁": "行政-物业", "停车": "行政-物业",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 预过滤 & 降级兜底
|
||||
# =============================================================================
|
||||
|
||||
def routing_keyword_prefilter(text: str) -> bool:
|
||||
"""路由关键词预过滤:检查文本是否包含非IT业务关键词。
|
||||
|
||||
只要命中任意一个路由关键词即返回 True,未命中返回 False。
|
||||
用于在调用 Dify 前快速过滤,减少不必要的 API 调用。
|
||||
|
||||
Args:
|
||||
text: 用户消息文本
|
||||
|
||||
Returns:
|
||||
bool: 是否包含路由关键词
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
return any(kw in text for kw in ROUTING_PREFILTER_KEYWORDS)
|
||||
|
||||
|
||||
def _keyword_fallback_category(text: str) -> Optional[str]:
|
||||
"""关键词降级兜底:Dify 不可用时通过关键词匹配业务类别。
|
||||
|
||||
遍历 ROUTING_KEYWORD_TO_CATEGORY 映射,命中第一个关键词即返回对应业务类别。
|
||||
|
||||
Args:
|
||||
text: 用户消息文本
|
||||
|
||||
Returns:
|
||||
Optional[str]: 业务类别(行政/人力资源/财务/法务/行政-物业),未命中返回 None
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
for kw, category in ROUTING_KEYWORD_TO_CATEGORY.items():
|
||||
if kw in text:
|
||||
return category
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Dify 统一意图识别调用
|
||||
# =============================================================================
|
||||
|
||||
async def detect_routing_intent(text: str, employee_id: str = "") -> dict:
|
||||
"""调用 Dify 统一意图识别,解析路由相关字段。
|
||||
|
||||
复用 approval.py 的 _call_dify_approval_intent 调用模式(Dify 原生 API),
|
||||
但本函数独立维护,解析路由关心的字段:
|
||||
- intent_type: approval/it_consult/non_it_routing/chitchat
|
||||
- business_category: 行政/人力资源/财务/法务/行政-物业(仅 non_it_routing 时有值)
|
||||
- routing_confidence: 0.0~1.0,≥0.7 触发名片推荐
|
||||
|
||||
使用与审批意图识别相同的 Dify 应用(同一 API Key),只是解析各自关心的字段。
|
||||
|
||||
Args:
|
||||
text: 用户消息文本
|
||||
employee_id: 员工 ID(可选,传给 Dify 的 user 字段)
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"intent_type": str,
|
||||
"business_category": str | None,
|
||||
"routing_confidence": float,
|
||||
"is_approval_request": bool,
|
||||
"confidence": float,
|
||||
"approval_type": str | None,
|
||||
}
|
||||
|
||||
Raises:
|
||||
Exception: Dify 调用失败或响应解析失败
|
||||
"""
|
||||
base_url = settings.approval_dify_base_url
|
||||
api_key = settings.approval_dify_api_key
|
||||
timeout = settings.approval_dify_timeout
|
||||
|
||||
if not base_url or not api_key:
|
||||
raise ValueError(
|
||||
"Dify 统一意图识别应用未配置"
|
||||
"(APPROVAL_DIFY_BASE_URL / APPROVAL_DIFY_API_KEY)"
|
||||
)
|
||||
|
||||
# 构建请求 URL:base_url + /v1/chat-messages(Dify 原生 API)
|
||||
url = f"{base_url.rstrip('/')}/v1/chat-messages"
|
||||
|
||||
body = {
|
||||
"inputs": {},
|
||||
"query": text,
|
||||
"response_mode": "blocking",
|
||||
"user": employee_id or "routing_detection",
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout)) as client:
|
||||
response = await client.post(url, json=body, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# 解析 Dify 原生响应:answer 字段包含 AI 返回的 JSON 字符串
|
||||
answer = data.get("answer", "")
|
||||
parsed = json.loads(answer)
|
||||
|
||||
# 解析统一意图识别的 6 个字段
|
||||
return {
|
||||
"is_approval_request": bool(parsed.get("is_approval_request", False)),
|
||||
"confidence": float(parsed.get("confidence", 0.0)),
|
||||
"approval_type": parsed.get("approval_type"),
|
||||
"intent_type": str(parsed.get("intent_type", "chitchat")),
|
||||
"business_category": parsed.get("business_category"),
|
||||
"routing_confidence": float(parsed.get("routing_confidence", 0.0)),
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 联系人查询
|
||||
# =============================================================================
|
||||
|
||||
async def get_contact_by_category(db, category: str) -> Optional[BusinessContact]:
|
||||
"""按业务类别查询联系人。
|
||||
|
||||
按 business_category + is_active=True 查询,取第一条有效联系人。
|
||||
P0 阶段单联系人推荐,P2 支持按服务区域匹配。
|
||||
|
||||
Args:
|
||||
db: 异步 DB session
|
||||
category: 业务类别(行政/人力资源/财务/法务/行政-物业)
|
||||
|
||||
Returns:
|
||||
Optional[BusinessContact]: 联系人对象,未找到返回 None
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(BusinessContact)
|
||||
.where(
|
||||
BusinessContact.business_category == category,
|
||||
BusinessContact.is_active == True, # noqa: E712
|
||||
)
|
||||
.order_by(BusinessContact.id)
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 名片三段式发送
|
||||
# =============================================================================
|
||||
|
||||
async def send_contact_card(
|
||||
db,
|
||||
conversation: Conversation,
|
||||
employee_id: str,
|
||||
contact: BusinessContact,
|
||||
reason: str,
|
||||
business_category: str,
|
||||
routing_confidence: float,
|
||||
) -> None:
|
||||
"""发送名片三段式消息(路由文本 → contact_card → 系统提示)。
|
||||
|
||||
完全参考 _handle_byod_query 模式:
|
||||
1. 创建路由说明文本消息(AI, text)→ 落库 + WS双通道推送
|
||||
2. 创建 contact_card 名片消息(AI, contact_card)→ 落库 + WS双通道推送
|
||||
3. 创建系统提示消息(system, system)→ 落库 + WS双通道推送
|
||||
|
||||
每条消息分别落库 + WS推送,与 PRD 4.3 交互流程一致。
|
||||
|
||||
Args:
|
||||
db: 异步 DB session
|
||||
conversation: 当前会话对象
|
||||
employee_id: 员工企微 UserID
|
||||
contact: 联系人对象
|
||||
reason: 路由说明文本(如"打印机问题属于行政设备范畴...")
|
||||
business_category: 业务类别
|
||||
routing_confidence: 路由置信度
|
||||
"""
|
||||
contact_data = contact.to_dict()
|
||||
extra_data: dict[str, Any] = {
|
||||
"contact": contact_data,
|
||||
"routing_reason": reason,
|
||||
"business_category": business_category,
|
||||
"routing_confidence": routing_confidence,
|
||||
}
|
||||
|
||||
# === 1. 路由说明文本消息 ===
|
||||
routing_text_msg = Message(
|
||||
conversation_id=conversation.id,
|
||||
sender_type="ai",
|
||||
sender_id="ai_bot",
|
||||
sender_name="Duckula(达寇拉)",
|
||||
content=reason,
|
||||
msg_type="text",
|
||||
is_read=True,
|
||||
)
|
||||
db.add(routing_text_msg)
|
||||
await db.flush()
|
||||
|
||||
await ws_manager.broadcast_to_employees([employee_id], {
|
||||
"type": "ai_reply",
|
||||
"data": {
|
||||
"message_id": str(routing_text_msg.id),
|
||||
"conversation_id": str(conversation.id),
|
||||
"sender_type": "ai",
|
||||
"sender_id": "ai_bot",
|
||||
"sender_name": "Duckula(达寇拉)",
|
||||
"content": reason,
|
||||
"msg_type": "text",
|
||||
"is_guidance": False,
|
||||
"ai_reply_count": conversation.ai_substantive_reply_count,
|
||||
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
|
||||
"conversation_status": conversation.status,
|
||||
},
|
||||
})
|
||||
try:
|
||||
await ws_manager.broadcast({
|
||||
"type": "new_message",
|
||||
"data": {
|
||||
"conversation_id": str(conversation.id),
|
||||
"message_id": str(routing_text_msg.id),
|
||||
"sender_type": "ai",
|
||||
"sender_id": "ai_bot",
|
||||
"sender_name": "Duckula(达寇拉)",
|
||||
"content": reason,
|
||||
"msg_type": "text",
|
||||
},
|
||||
})
|
||||
except Exception as ws_err:
|
||||
logger.warning(f"路由文本 WS 广播给坐席失败: {ws_err}")
|
||||
|
||||
# === 2. contact_card 名片消息 ===
|
||||
contact_card_msg = Message(
|
||||
conversation_id=conversation.id,
|
||||
sender_type="ai",
|
||||
sender_id="ai_bot",
|
||||
sender_name="Duckula(达寇拉)",
|
||||
content=f"为您推荐{business_category}服务联系人:{contact.name}",
|
||||
msg_type="contact_card",
|
||||
extra_data=extra_data,
|
||||
is_read=True,
|
||||
)
|
||||
db.add(contact_card_msg)
|
||||
await db.flush()
|
||||
|
||||
await ws_manager.broadcast_to_employees([employee_id], {
|
||||
"type": "ai_reply",
|
||||
"data": {
|
||||
"message_id": str(contact_card_msg.id),
|
||||
"conversation_id": str(conversation.id),
|
||||
"sender_type": "ai",
|
||||
"sender_id": "ai_bot",
|
||||
"sender_name": "Duckula(达寇拉)",
|
||||
"content": f"为您推荐{business_category}服务联系人:{contact.name}",
|
||||
"msg_type": "contact_card",
|
||||
"extra_data": extra_data,
|
||||
"is_guidance": False,
|
||||
"ai_reply_count": conversation.ai_substantive_reply_count,
|
||||
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
|
||||
"conversation_status": conversation.status,
|
||||
},
|
||||
})
|
||||
try:
|
||||
await ws_manager.broadcast({
|
||||
"type": "new_message",
|
||||
"data": {
|
||||
"conversation_id": str(conversation.id),
|
||||
"message_id": str(contact_card_msg.id),
|
||||
"sender_type": "ai",
|
||||
"sender_id": "ai_bot",
|
||||
"sender_name": "Duckula(达寇拉)",
|
||||
"content": f"为您推荐{business_category}服务联系人:{contact.name}",
|
||||
"msg_type": "contact_card",
|
||||
"extra_data": extra_data,
|
||||
},
|
||||
})
|
||||
except Exception as ws_err:
|
||||
logger.warning(f"名片卡片 WS 广播给坐席失败: {ws_err}")
|
||||
|
||||
# === 3. 系统提示消息 ===
|
||||
system_text = "以上为AI自动推荐,点击名片可直接发起企微聊天"
|
||||
system_msg = Message(
|
||||
conversation_id=conversation.id,
|
||||
sender_type="system",
|
||||
sender_id="system",
|
||||
sender_name="系统",
|
||||
content=system_text,
|
||||
msg_type="system",
|
||||
is_read=True,
|
||||
)
|
||||
db.add(system_msg)
|
||||
await db.flush()
|
||||
|
||||
await ws_manager.broadcast_to_employees([employee_id], {
|
||||
"type": "ai_reply",
|
||||
"data": {
|
||||
"message_id": str(system_msg.id),
|
||||
"conversation_id": str(conversation.id),
|
||||
"sender_type": "system",
|
||||
"sender_id": "system",
|
||||
"sender_name": "系统",
|
||||
"content": system_text,
|
||||
"msg_type": "system",
|
||||
"is_guidance": False,
|
||||
"ai_reply_count": conversation.ai_substantive_reply_count,
|
||||
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
|
||||
"conversation_status": conversation.status,
|
||||
},
|
||||
})
|
||||
try:
|
||||
await ws_manager.broadcast({
|
||||
"type": "new_message",
|
||||
"data": {
|
||||
"conversation_id": str(conversation.id),
|
||||
"message_id": str(system_msg.id),
|
||||
"sender_type": "system",
|
||||
"sender_id": "system",
|
||||
"sender_name": "系统",
|
||||
"content": system_text,
|
||||
"msg_type": "system",
|
||||
},
|
||||
})
|
||||
except Exception as ws_err:
|
||||
logger.warning(f"系统提示 WS 广播给坐席失败: {ws_err}")
|
||||
|
||||
# 更新会话状态(路由推荐视为一次实质性 AI 回复)
|
||||
conversation.ai_substantive_reply_count += 1
|
||||
conversation.updated_at = datetime.now()
|
||||
db.add(conversation)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
f"路由名片发送完成: employee_id={employee_id}, category={business_category}, "
|
||||
f"contact={contact.name}, confidence={routing_confidence}"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 路由事件记录(P1)
|
||||
# =============================================================================
|
||||
|
||||
async def record_routing_event(
|
||||
db,
|
||||
conversation_id: str,
|
||||
employee_id: str,
|
||||
message_content: str,
|
||||
business_category: str,
|
||||
routing_confidence: float,
|
||||
contact: Optional[BusinessContact],
|
||||
) -> None:
|
||||
"""记录路由命中事件(P1)。
|
||||
|
||||
为后续优化 Prompt 准确率、分析高频非IT业务提供数据支撑。
|
||||
|
||||
Args:
|
||||
db: 异步 DB session
|
||||
conversation_id: 会话ID
|
||||
employee_id: 员工ID
|
||||
message_content: 触发路由的员工消息(截断至500字)
|
||||
business_category: 业务类别
|
||||
routing_confidence: 路由置信度
|
||||
contact: 推荐的联系人对象(可能为 None)
|
||||
"""
|
||||
try:
|
||||
event = RoutingEvent(
|
||||
conversation_id=conversation_id,
|
||||
employee_id=employee_id,
|
||||
message_content=message_content[:500],
|
||||
business_category=business_category,
|
||||
routing_confidence=routing_confidence,
|
||||
contact_id=contact.id if contact else None,
|
||||
contact_name=contact.name if contact else "",
|
||||
is_clicked=False,
|
||||
)
|
||||
db.add(event)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
# 路由事件记录失败不影响主流程,仅记录 warning
|
||||
logger.warning(f"路由事件记录失败: {e}")
|
||||
@@ -20,7 +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.avatar_service import clean_avatar_url, wrap_avatar_url
|
||||
from app.services.wecom_service import WecomService
|
||||
from app.utils.response import (
|
||||
AppException,
|
||||
@@ -912,8 +912,11 @@ class SessionService:
|
||||
cached_avatar = await self.redis_client.get(cache_key)
|
||||
if cached_avatar:
|
||||
# 兼容历史缓存中可能带查询参数,统一清理后返回
|
||||
return clean_avatar_url(
|
||||
cached_avatar.decode("utf-8") if isinstance(cached_avatar, bytes) else cached_avatar
|
||||
# 缓存中存储的是 clean_avatar_url 的结果(不含代理包装),返回时再包装
|
||||
return wrap_avatar_url(
|
||||
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}")
|
||||
@@ -937,7 +940,8 @@ class SessionService:
|
||||
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 cleaned
|
||||
# 缓存中存储 cleaned(不含代理包装),返回时再包装
|
||||
return wrap_avatar_url(cleaned)
|
||||
else:
|
||||
logger.info(f"employees表无头像记录: employee_id={employee_id}")
|
||||
|
||||
@@ -973,7 +977,8 @@ class SessionService:
|
||||
except Exception as e:
|
||||
logger.warning(f"从企微API获取头像失败: employee_id={employee_id}, error={e}")
|
||||
|
||||
return avatar
|
||||
# 返回时包装为代理 URL(缓存和 DB 中存储的是 clean_avatar_url 的结果)
|
||||
return wrap_avatar_url(avatar)
|
||||
|
||||
async def invite_participants(
|
||||
self,
|
||||
@@ -1003,9 +1008,22 @@ class SessionService:
|
||||
# 1. 校验会话
|
||||
conversation = await self._get_conversation(conversation_id)
|
||||
|
||||
# 2. 权限:只有主责坐席可以邀请
|
||||
if conversation.assigned_agent_id != inviter_agent_id:
|
||||
raise AppException(3030, "只有主责坐席才能邀请人员加入会话")
|
||||
# 2. 权限:主责坐席、会话发起人、或已被邀请的参与者可以邀请
|
||||
is_primary_agent = conversation.assigned_agent_id == inviter_agent_id
|
||||
is_creator = conversation.employee_id == inviter_agent_id
|
||||
# 检查是否为已在会话中的参与者(participants 是结构化对象数组)
|
||||
# 兼容 "id" 和 "employee_id" 两种可能的键名
|
||||
is_participant = False
|
||||
if conversation.participants:
|
||||
for p in conversation.participants:
|
||||
if isinstance(p, dict) and (
|
||||
p.get("id") == inviter_agent_id
|
||||
or p.get("employee_id") == inviter_agent_id
|
||||
):
|
||||
is_participant = True
|
||||
break
|
||||
if not (is_primary_agent or is_creator or is_participant):
|
||||
raise AppException(3030, "只有会话参与者才能邀请人员加入会话")
|
||||
|
||||
# 3. 校验:会话必须是服务中状态
|
||||
if conversation.status != "serving":
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 待办聚合服务
|
||||
# =============================================================================
|
||||
# 说明:聚合企微审批 + ITSM 工单两个异构数据源为统一代办列表。
|
||||
# - Redis 缓存(TTL 45s)减少重复外部 API 调用
|
||||
# - asyncio.gather(return_exceptions=True) 并行查询两个数据源
|
||||
# - 任一数据源失败不影响另一个返回
|
||||
# - 按优先级排序 urgent → high → normal
|
||||
#
|
||||
# 缓存 Key 设计:todo:cache:{agent_userid}:{todo_type_or_all}
|
||||
# =============================================================================
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.services.itsm_service import ITSMService
|
||||
from app.services.todo_source_service import ApprovalTodoService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 缓存 TTL(秒)
|
||||
CACHE_TTL = 45
|
||||
|
||||
# 优先级排序权重(数字越小排越前)
|
||||
PRIORITY_ORDER: Dict[str, int] = {"urgent": 0, "high": 1, "normal": 2}
|
||||
|
||||
|
||||
class TodoAggregatorService:
|
||||
"""待办聚合服务 — 统一聚合企微审批和 ITSM 工单两个数据源。
|
||||
|
||||
职责:
|
||||
1. 检查 Redis 缓存,命中则直接返回
|
||||
2. 未命中则并行查询两个数据源(asyncio.gather + return_exceptions)
|
||||
3. 合并结果,按类型过滤,按优先级排序
|
||||
4. 写入 Redis 缓存(TTL 45s)
|
||||
5. 提供 get_todo_detail 按类型路由到对应 Service
|
||||
|
||||
Attributes:
|
||||
redis: Redis 异步客户端
|
||||
"""
|
||||
|
||||
def __init__(self, redis: aioredis.Redis):
|
||||
"""初始化聚合服务。
|
||||
|
||||
Args:
|
||||
redis: Redis 异步客户端实例
|
||||
"""
|
||||
self.redis = redis
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 缓存 Key 辅助
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _cache_key(agent_userid: str, todo_type: Optional[str]) -> str:
|
||||
"""构建 Redis 缓存 Key。
|
||||
|
||||
格式:todo:cache:{agent_userid}:{todo_type_or_all}
|
||||
|
||||
Args:
|
||||
agent_userid: 坐席企微 userid
|
||||
todo_type: 类型过滤(all/approval/ticket),None 表示全部
|
||||
|
||||
Returns:
|
||||
str: Redis 缓存 Key
|
||||
"""
|
||||
type_key = todo_type if todo_type else "all"
|
||||
return f"todo:cache:{agent_userid}:{type_key}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 列表查询
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_todo_list(
|
||||
self, agent_userid: str, todo_type: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""获取聚合待办列表。
|
||||
|
||||
流程:
|
||||
1. 检查 Redis 缓存
|
||||
2. 命中 → 返回(标记 cached:true)
|
||||
3. 未命中 → 并行查询两个数据源
|
||||
4. 过滤异常结果(isinstance(result, Exception) 跳过)
|
||||
5. 按 todo_type 过滤
|
||||
6. 按优先级排序 urgent → high → normal
|
||||
7. 写 Redis 缓存 TTL 45s
|
||||
8. 返回 {items, total, cached:false}
|
||||
|
||||
Args:
|
||||
agent_userid: 当前坐席的企微 userid
|
||||
todo_type: 类型过滤(approval/ticket),None 或 "all" 表示全部
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: {items: List, total: int, cached: bool}
|
||||
"""
|
||||
# 1. 检查缓存
|
||||
cached_data = await self._get_from_cache(agent_userid, todo_type)
|
||||
if cached_data is not None:
|
||||
cached_data["cached"] = True
|
||||
return cached_data
|
||||
|
||||
# 2. 并行查询两个数据源
|
||||
approval_svc = ApprovalTodoService(agent_userid, self.redis)
|
||||
itsm_svc = ITSMService(agent_userid, self.redis)
|
||||
|
||||
approval_result, itsm_result = await asyncio.gather(
|
||||
approval_svc.get_todo_list(),
|
||||
itsm_svc.get_todo_list(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
# 3. 合并结果(容错:异常的数据源跳过)
|
||||
all_items: List[Dict[str, Any]] = []
|
||||
|
||||
if isinstance(approval_result, Exception):
|
||||
logger.error(f"企微审批数据源查询失败: {approval_result}")
|
||||
elif isinstance(approval_result, list):
|
||||
all_items.extend(approval_result)
|
||||
|
||||
if isinstance(itsm_result, Exception):
|
||||
logger.error(f"ITSM 数据源查询失败: {itsm_result}")
|
||||
elif isinstance(itsm_result, list):
|
||||
all_items.extend(itsm_result)
|
||||
|
||||
# 4. 按类型过滤
|
||||
if todo_type and todo_type != "all":
|
||||
all_items = [item for item in all_items if item.get("type") == todo_type]
|
||||
|
||||
# 5. 按优先级排序
|
||||
all_items.sort(
|
||||
key=lambda x: PRIORITY_ORDER.get(x.get("priority", "normal"), 3)
|
||||
)
|
||||
|
||||
# 6. 构建返回数据
|
||||
result = {
|
||||
"items": all_items,
|
||||
"total": len(all_items),
|
||||
"cached": False,
|
||||
}
|
||||
|
||||
# 7. 写缓存
|
||||
await self._set_to_cache(agent_userid, todo_type, result)
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 详情查询
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_todo_detail(
|
||||
self, agent_userid: str, item_id: str, todo_type: Optional[str] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""获取单条待办详情。
|
||||
|
||||
从 item_id 解析类型前缀(approval:xxx / ticket:xxx),
|
||||
路由到对应的数据源 Service 查询详情。
|
||||
|
||||
Args:
|
||||
agent_userid: 当前坐席的企微 userid
|
||||
item_id: 待办 ID(格式:{type}:{原始ID})
|
||||
todo_type: 类型提示(可选,如未提供则从 item_id 解析)
|
||||
|
||||
Returns:
|
||||
Optional[Dict[str, Any]]: TodoItemData 格式的待办详情
|
||||
"""
|
||||
# 解析类型前缀
|
||||
if not todo_type:
|
||||
if ":" in item_id:
|
||||
todo_type = item_id.split(":", 1)[0]
|
||||
else:
|
||||
logger.error(f"无法从 item_id 解析类型: {item_id}")
|
||||
return None
|
||||
|
||||
# 提取原始 ID(去掉类型前缀)
|
||||
original_id = item_id.split(":", 1)[1] if ":" in item_id else item_id
|
||||
|
||||
if todo_type == "approval":
|
||||
svc = ApprovalTodoService(agent_userid, self.redis)
|
||||
return await svc.get_todo_detail(original_id)
|
||||
|
||||
elif todo_type == "ticket":
|
||||
svc = ITSMService(agent_userid, self.redis)
|
||||
return await svc.get_todo_detail(original_id)
|
||||
|
||||
else:
|
||||
logger.error(f"未知的待办类型: {todo_type}, item_id={item_id}")
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 缓存操作
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _get_from_cache(
|
||||
self, agent_userid: str, todo_type: Optional[str]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""从 Redis 缓存读取待办列表。
|
||||
|
||||
Args:
|
||||
agent_userid: 坐席企微 userid
|
||||
todo_type: 类型过滤
|
||||
|
||||
Returns:
|
||||
Optional[Dict[str, Any]]: 缓存的数据,未命中返回 None
|
||||
"""
|
||||
try:
|
||||
key = self._cache_key(agent_userid, todo_type)
|
||||
cached = await self.redis.get(key)
|
||||
if cached:
|
||||
# 处理 bytes 和 string 两种情况(取决于 decode_responses 配置)
|
||||
if isinstance(cached, bytes):
|
||||
cached = cached.decode("utf-8")
|
||||
return json.loads(cached)
|
||||
except Exception as e:
|
||||
logger.warning(f"读取待办缓存失败: {e}")
|
||||
return None
|
||||
|
||||
async def _set_to_cache(
|
||||
self, agent_userid: str, todo_type: Optional[str], data: Dict[str, Any]
|
||||
) -> None:
|
||||
"""将待办列表写入 Redis 缓存。
|
||||
|
||||
Args:
|
||||
agent_userid: 坐席企微 userid
|
||||
todo_type: 类型过滤
|
||||
data: 待缓存的数据
|
||||
"""
|
||||
try:
|
||||
key = self._cache_key(agent_userid, todo_type)
|
||||
# 缓存时移除 cached 标记(读取时统一添加)
|
||||
cache_data = {k: v for k, v in data.items() if k != "cached"}
|
||||
await self.redis.setex(key, CACHE_TTL, json.dumps(cache_data, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
logger.warning(f"写入待办缓存失败: {e}")
|
||||
|
||||
async def _invalidate_cache(self, agent_userid: str) -> None:
|
||||
"""使指定坐席的所有待办缓存失效。
|
||||
|
||||
删除 todo:cache:{agent_userid}:* 的所有缓存 Key。
|
||||
用于强制刷新场景。
|
||||
|
||||
Args:
|
||||
agent_userid: 坐席企微 userid
|
||||
"""
|
||||
try:
|
||||
pattern = f"todo:cache:{agent_userid}:*"
|
||||
keys = await self.redis.keys(pattern)
|
||||
if keys:
|
||||
await self.redis.delete(*keys)
|
||||
logger.info(f"已清除坐席 {agent_userid} 的待办缓存: {len(keys)} 个 key")
|
||||
except Exception as e:
|
||||
logger.warning(f"清除待办缓存失败: {e}")
|
||||
@@ -0,0 +1,495 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 待办数据源 Service 层(抽象基类 + 企微审批实现)
|
||||
# =============================================================================
|
||||
# 说明:定义待办数据源的统一抽象接口,并提供企微审批数据源的具体实现。
|
||||
# - TodoSourceService: 抽象基类,定义 get_todo_list / get_todo_detail 接口
|
||||
# - ApprovalTodoService: 企微审批实现,聚合 getapprovaldata + getapprovaldetail
|
||||
#
|
||||
# 设计原则:
|
||||
# 1. 策略模式 — 不同数据源实现同一接口,TodoAggregatorService 可透明替换
|
||||
# 2. 容错隔离 — 单个数据源失败不影响其他数据源
|
||||
# 3. 并发优化 — 企微审批详情查询使用 Semaphore 限制并发,防止 API 限流
|
||||
# =============================================================================
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.api.approval import (
|
||||
APPROVAL_TEMPLATES,
|
||||
_extract_current_approver,
|
||||
get_approval_detail,
|
||||
)
|
||||
from app.utils.token_manager import TokenManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 企微 getapprovalinfo API 地址(旧接口 getapprovaldata 已废弃,改用 getapprovalinfo)
|
||||
WECOM_GETAPPROVALINFO_URL = "https://qyapi.weixin.qq.com/cgi-bin/oa/getapprovalinfo"
|
||||
|
||||
# 企微审批详情并发查询上限(Semaphore),防止触发企微 API 限流
|
||||
APPROVAL_DETAIL_CONCURRENCY = 10
|
||||
|
||||
# 查询审批数据的时间范围(最近 N 天)
|
||||
APPROVAL_QUERY_DAYS = 7
|
||||
|
||||
# 企微 getapprovaldata 单页查询上限
|
||||
APPROVAL_PAGE_SIZE = 100
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 模块级工具函数
|
||||
# ===========================================================================
|
||||
|
||||
def _extract_template_ids_from_templates() -> List[str]:
|
||||
"""从 APPROVAL_TEMPLATES 中提取企微审批模板 ID 列表。
|
||||
|
||||
APPROVAL_TEMPLATES 中每个模板的 url 字段可能包含 template_id 查询参数
|
||||
(仅 location=="企微审批" 的模板才有)。此函数解析所有 URL,提取有效的
|
||||
template_id,用于 getapprovaldata 的 filters 过滤。
|
||||
|
||||
Returns:
|
||||
List[str]: 企微审批模板 ID 列表(去重)
|
||||
"""
|
||||
template_ids: List[str] = []
|
||||
seen: set = set()
|
||||
for template in APPROVAL_TEMPLATES.values():
|
||||
url = template.get("url", "")
|
||||
if not url:
|
||||
continue
|
||||
# 解析 URL 中的 query 参数
|
||||
parsed = urlparse(url)
|
||||
# 企微审批 URL 的 query 在 fragment 中(#/?template_id=xxx)
|
||||
# urlparse 会把 # 后面的内容放入 fragment
|
||||
fragment = parsed.fragment or ""
|
||||
query_string = ""
|
||||
if "?" in fragment:
|
||||
query_string = fragment.split("?", 1)[1]
|
||||
elif parsed.query:
|
||||
query_string = parsed.query
|
||||
|
||||
if query_string:
|
||||
params = parse_qs(query_string)
|
||||
tid_list = params.get("template_id", [])
|
||||
for tid in tid_list:
|
||||
if tid and tid not in seen:
|
||||
seen.add(tid)
|
||||
template_ids.append(tid)
|
||||
return template_ids
|
||||
|
||||
|
||||
def _build_template_id_name_map() -> Dict[str, str]:
|
||||
"""构建 企微template_id → 模板名称 的映射表。
|
||||
|
||||
用于在映射 TodoItemData 时,通过 template_id 查找对应的审批模板名称。
|
||||
|
||||
Returns:
|
||||
Dict[str, str]: {企微template_id: 模板名称}
|
||||
"""
|
||||
mapping: Dict[str, str] = {}
|
||||
for template in APPROVAL_TEMPLATES.values():
|
||||
url = template.get("url", "")
|
||||
name = template.get("name", "")
|
||||
if not url:
|
||||
continue
|
||||
parsed = urlparse(url)
|
||||
fragment = parsed.fragment or ""
|
||||
query_string = ""
|
||||
if "?" in fragment:
|
||||
query_string = fragment.split("?", 1)[1]
|
||||
elif parsed.query:
|
||||
query_string = parsed.query
|
||||
|
||||
if query_string:
|
||||
params = parse_qs(query_string)
|
||||
tid_list = params.get("template_id", [])
|
||||
for tid in tid_list:
|
||||
if tid:
|
||||
mapping[tid] = name
|
||||
return mapping
|
||||
|
||||
|
||||
def _apply_time_to_iso(apply_time: Any) -> str:
|
||||
"""将企微 apply_time(秒级时间戳)转换为 ISO 8601 格式字符串。
|
||||
|
||||
Args:
|
||||
apply_time: 企微审批的 apply_time 字段(int 秒级时间戳,或已格式化字符串)
|
||||
|
||||
Returns:
|
||||
str: ISO 8601 格式时间字符串
|
||||
"""
|
||||
if not apply_time:
|
||||
return ""
|
||||
try:
|
||||
if isinstance(apply_time, (int, float)):
|
||||
# 企微 apply_time 为秒级时间戳
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(int(apply_time)))
|
||||
return str(apply_time)
|
||||
except Exception:
|
||||
return str(apply_time)
|
||||
|
||||
|
||||
# 模块级缓存:企微审批模板 ID 列表(启动时计算一次)
|
||||
_APPROVAL_TEMPLATE_IDS: List[str] = _extract_template_ids_from_templates()
|
||||
|
||||
# 模块级缓存:template_id → 模板名称映射
|
||||
_TEMPLATE_ID_NAME_MAP: Dict[str, str] = _build_template_id_name_map()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 抽象基类
|
||||
# ===========================================================================
|
||||
|
||||
class TodoSourceService(ABC):
|
||||
"""待办数据源抽象基类。
|
||||
|
||||
所有待办数据源(企微审批、ITSM 工单等)需实现此接口,
|
||||
以便 TodoAggregatorService 统一聚合。
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_todo_list(self) -> List[Dict[str, Any]]:
|
||||
"""获取待办列表。
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: TodoItemData 格式的待办列表
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_todo_detail(self, item_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取单条待办详情。
|
||||
|
||||
Args:
|
||||
item_id: 待办原始 ID(不含类型前缀)
|
||||
|
||||
Returns:
|
||||
Optional[Dict[str, Any]]: TodoItemData 格式的待办详情,不存在返回 None
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 企微审批数据源实现
|
||||
# ===========================================================================
|
||||
|
||||
class ApprovalTodoService(TodoSourceService):
|
||||
"""企微审批待办数据源实现。
|
||||
|
||||
通过企微 OA API 获取当前坐席待处理的审批单:
|
||||
1. getapprovaldata — 按 sp_status=1 + 模板 ID 过滤,获取审批单号列表
|
||||
2. getapprovaldetail — 并发获取每个审批单的详情
|
||||
3. _extract_current_approver — 过滤当前审批人是当前坐席的审批单
|
||||
4. _map_to_todo_item — 映射为统一 TodoItemData 格式
|
||||
|
||||
Attributes:
|
||||
agent_userid: 当前坐席的企微 userid
|
||||
redis: Redis 异步客户端(用于获取 access_token)
|
||||
"""
|
||||
|
||||
def __init__(self, agent_userid: str, redis: aioredis.Redis):
|
||||
"""初始化企微审批数据源服务。
|
||||
|
||||
Args:
|
||||
agent_userid: 当前坐席的企微 userid
|
||||
redis: Redis 异步客户端实例
|
||||
"""
|
||||
self.agent_userid = agent_userid
|
||||
self.redis = redis
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 公开接口
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_todo_list(self) -> List[Dict[str, Any]]:
|
||||
"""获取当前坐席待处理的企微审批列表。
|
||||
|
||||
流程:
|
||||
1. 获取审批 access_token
|
||||
2. 调用 getapprovaldata 获取审批单号列表(sp_status=1,最近7天)
|
||||
3. 并发调用 getapprovaldetail 获取每个审批单详情(Semaphore 限流)
|
||||
4. 用 _extract_current_approver 过滤出当前审批人是当前坐席的审批单
|
||||
5. 映射为统一 TodoItemData 格式
|
||||
|
||||
异常处理:任何步骤失败都返回空列表并记日志,不抛出异常。
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: TodoItemData 格式的待办列表
|
||||
"""
|
||||
try:
|
||||
# 1. 获取 access_token
|
||||
access_token = await self._get_access_token()
|
||||
if not access_token:
|
||||
logger.error("获取企微审批 access_token 失败,返回空列表")
|
||||
return []
|
||||
|
||||
# 2. 获取审批单号列表
|
||||
sp_no_list = await self._fetch_approval_sp_no_list(access_token)
|
||||
if not sp_no_list:
|
||||
logger.info("企微审批待处理列表为空")
|
||||
return []
|
||||
|
||||
logger.info(f"企微审批待处理审批单号列表: {len(sp_no_list)} 条")
|
||||
|
||||
# 3. 并发获取审批详情
|
||||
details = await self._fetch_approval_details(access_token, sp_no_list)
|
||||
if not details:
|
||||
logger.info("企微审批详情获取失败或为空")
|
||||
return []
|
||||
|
||||
# 3.5 按模板 ID 过滤(企微 API 每个 key 只能出现一次,
|
||||
# 无法在 API 层按多个 template_id 过滤,需在代码层过滤)
|
||||
if _APPROVAL_TEMPLATE_IDS:
|
||||
before_count = len(details)
|
||||
details = [
|
||||
d for d in details
|
||||
if d.get("info", {}).get("template_id", "") in _APPROVAL_TEMPLATE_IDS
|
||||
]
|
||||
logger.info(
|
||||
f"企微审批按模板ID过滤后: {len(details)}/{before_count} 条"
|
||||
)
|
||||
|
||||
# 4. 过滤当前审批人是当前坐席的审批单
|
||||
filtered = self._filter_by_current_approver(details)
|
||||
logger.info(
|
||||
f"企微审批过滤后(当前审批人={self.agent_userid}): "
|
||||
f"{len(filtered)}/{len(details)} 条"
|
||||
)
|
||||
|
||||
# 5. 映射为 TodoItemData
|
||||
todo_items = [self._map_to_todo_item(d) for d in filtered]
|
||||
return todo_items
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取企微审批待办列表失败: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
async def get_todo_detail(self, item_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取单条企微审批详情。
|
||||
|
||||
Args:
|
||||
item_id: 审批单号 sp_no(不含 "approval:" 前缀)
|
||||
|
||||
Returns:
|
||||
Optional[Dict[str, Any]]: TodoItemData 格式的审批详情
|
||||
"""
|
||||
try:
|
||||
access_token = await self._get_access_token()
|
||||
if not access_token:
|
||||
logger.error("获取企微审批 access_token 失败")
|
||||
return None
|
||||
|
||||
detail = await self._fetch_approval_detail(access_token, item_id)
|
||||
if not detail:
|
||||
return None
|
||||
|
||||
return self._map_to_todo_item(detail)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取企微审批详情失败: sp_no={item_id}, error={e}", exc_info=True)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 私有方法
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _get_access_token(self) -> str:
|
||||
"""获取企微 access_token(使用IT支持应用Secret,IP已在白名单中)。"""
|
||||
manager = TokenManager(self.redis)
|
||||
try:
|
||||
return await manager.get_token()
|
||||
finally:
|
||||
await manager.close()
|
||||
|
||||
async def _fetch_approval_sp_no_list(self, access_token: str) -> List[str]:
|
||||
"""调用企微 getapprovalinfo API 获取审批单号列表。
|
||||
|
||||
使用 new_cursor 分页循环,查询最近 7 天内 sp_status=1(审批中)的审批单,
|
||||
并按预置的模板 ID 列表过滤。
|
||||
|
||||
注意:旧接口 getapprovaldata 已废弃(返回404),改用 getapprovalinfo。
|
||||
|
||||
Args:
|
||||
access_token: 企微审批 access_token
|
||||
|
||||
Returns:
|
||||
List[str]: 审批单号列表
|
||||
"""
|
||||
# 时间范围:最近 7 天
|
||||
endtime = int(time.time())
|
||||
starttime = endtime - APPROVAL_QUERY_DAYS * 24 * 3600
|
||||
|
||||
# 构建 filters:仅 sp_status=1(审批中)
|
||||
# 注意:企微 getapprovalinfo API 每个 key 只能出现一次,
|
||||
# 不能在 API 层按多个 template_id 过滤,需在代码层面过滤。
|
||||
filters: List[Dict[str, Any]] = [
|
||||
{"key": "sp_status", "value": 1},
|
||||
]
|
||||
|
||||
sp_no_list: List[str] = []
|
||||
new_cursor = ""
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(timeout=30.0, connect=10.0, read=30.0)
|
||||
) as client:
|
||||
while True:
|
||||
payload = {
|
||||
"starttime": str(starttime),
|
||||
"endtime": str(endtime),
|
||||
"new_cursor": new_cursor,
|
||||
"size": APPROVAL_PAGE_SIZE,
|
||||
"filters": filters,
|
||||
}
|
||||
params = {"access_token": access_token}
|
||||
|
||||
response = await client.post(
|
||||
WECOM_GETAPPROVALINFO_URL, params=params, json=payload
|
||||
)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode") != 0:
|
||||
logger.error(
|
||||
f"getapprovalinfo 调用失败: errcode={result.get('errcode')}, "
|
||||
f"errmsg={result.get('errmsg')}"
|
||||
)
|
||||
break
|
||||
|
||||
# getapprovalinfo 返回 sp_no_list(字符串数组),非旧接口的 data
|
||||
page_sp_no_list = result.get("sp_no_list", [])
|
||||
sp_no_list.extend(page_sp_no_list)
|
||||
|
||||
# 检查是否还有下一页(new_next_cursor 为空表示无更多数据)
|
||||
next_cursor = result.get("new_next_cursor", "")
|
||||
if not next_cursor or next_cursor == new_cursor:
|
||||
break
|
||||
new_cursor = next_cursor
|
||||
|
||||
return sp_no_list
|
||||
|
||||
async def _fetch_approval_detail(self, access_token: str, sp_no: str) -> Optional[dict]:
|
||||
"""调用企微 getapprovaldetail API 获取单条审批详情。
|
||||
|
||||
复用 approval.py 中已有的 get_approval_detail 函数。
|
||||
|
||||
Args:
|
||||
access_token: 企微审批 access_token
|
||||
sp_no: 审批单号
|
||||
|
||||
Returns:
|
||||
Optional[dict]: 企微 API 返回的完整审批详情,失败返回 None
|
||||
"""
|
||||
try:
|
||||
return await get_approval_detail(access_token, sp_no)
|
||||
except Exception as e:
|
||||
logger.warning(f"获取审批详情失败: sp_no={sp_no}, error={e}")
|
||||
return None
|
||||
|
||||
async def _fetch_approval_details(
|
||||
self, access_token: str, sp_no_list: List[str]
|
||||
) -> List[dict]:
|
||||
"""并发获取多个审批单的详情。
|
||||
|
||||
使用 asyncio.Semaphore 限制并发数(默认 10),防止企微 API 限流。
|
||||
单个审批单查询失败不影响其他审批单。
|
||||
|
||||
Args:
|
||||
access_token: 企微审批 access_token
|
||||
sp_no_list: 审批单号列表
|
||||
|
||||
Returns:
|
||||
List[dict]: 成功获取的审批详情列表
|
||||
"""
|
||||
semaphore = asyncio.Semaphore(APPROVAL_DETAIL_CONCURRENCY)
|
||||
|
||||
async def _fetch_one(sp_no: str) -> Optional[dict]:
|
||||
async with semaphore:
|
||||
return await self._fetch_approval_detail(access_token, sp_no)
|
||||
|
||||
tasks = [_fetch_one(sp_no) for sp_no in sp_no_list]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
details: List[dict] = []
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
logger.warning(f"审批详情查询异常: {result}")
|
||||
continue
|
||||
if result is not None:
|
||||
details.append(result)
|
||||
|
||||
return details
|
||||
|
||||
def _filter_by_current_approver(self, details: List[dict]) -> List[dict]:
|
||||
"""过滤当前审批人是当前坐席的审批单。
|
||||
|
||||
使用 approval.py 中的 _extract_current_approver 提取当前审批人 userid,
|
||||
仅保留当前审批人等于 agent_userid 的审批单。
|
||||
|
||||
Args:
|
||||
details: 企微 getapprovaldetail 返回的审批详情列表
|
||||
|
||||
Returns:
|
||||
List[dict]: 过滤后的审批详情列表
|
||||
"""
|
||||
filtered: List[dict] = []
|
||||
for detail in details:
|
||||
current_approver = _extract_current_approver(detail)
|
||||
if current_approver and current_approver == self.agent_userid:
|
||||
filtered.append(detail)
|
||||
return filtered
|
||||
|
||||
def _map_to_todo_item(self, detail: dict) -> Dict[str, Any]:
|
||||
"""将企微审批详情映射为统一 TodoItemData 格式。
|
||||
|
||||
映射规则参考系统设计文档 §8.1:
|
||||
- id: "approval:{sp_no}"
|
||||
- type: "approval"
|
||||
- title: sp_name
|
||||
- priority: "high"(企微无优先级概念,默认 high)
|
||||
- status: "pending"(sp_status=1 审批中统一映射为 pending)
|
||||
- description: 包含 sp_no、template_name、applicant 等字段
|
||||
|
||||
Args:
|
||||
detail: 企微 getapprovaldetail 返回的完整审批详情
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: TodoItemData 格式的待办事项
|
||||
"""
|
||||
info = detail.get("info", {})
|
||||
|
||||
sp_no = info.get("sp_no", "")
|
||||
sp_name = info.get("sp_name", "")
|
||||
sp_status = info.get("sp_status", 1)
|
||||
template_id = info.get("template_id", "")
|
||||
apply_time = info.get("apply_time", 0)
|
||||
applyer_userid = info.get("applyer", {}).get("userid", "")
|
||||
current_approver = _extract_current_approver(detail)
|
||||
template_name = _TEMPLATE_ID_NAME_MAP.get(template_id, sp_name)
|
||||
|
||||
apply_time_iso = _apply_time_to_iso(apply_time)
|
||||
|
||||
return {
|
||||
"id": f"approval:{sp_no}",
|
||||
"type": "approval",
|
||||
"title": sp_name or template_name or "企微审批",
|
||||
"priority": "high",
|
||||
"description": {
|
||||
"sp_no": sp_no,
|
||||
"template_name": template_name,
|
||||
"template_id": template_id,
|
||||
"applicant": applyer_userid,
|
||||
"apply_time": apply_time,
|
||||
"sp_status": sp_status,
|
||||
"current_approver": current_approver or "",
|
||||
},
|
||||
"status": "pending",
|
||||
"assigned_agent_id": current_approver,
|
||||
"corp_id": "",
|
||||
"created_at": apply_time_iso,
|
||||
"updated_at": apply_time_iso,
|
||||
}
|
||||
@@ -46,6 +46,10 @@ class WecomService:
|
||||
)
|
||||
# 内存缓存(Redis 不可用时的降级方案)
|
||||
self._token_cache: Optional[str] = None
|
||||
# 通讯录同步 token 的内存缓存(与应用 token 区分,避免互相覆盖)
|
||||
self._contact_token_cache: Optional[str] = None
|
||||
# 会议室 API token 的内存缓存(使用独立 secret,与应用/通讯录 token 区分)
|
||||
self._meetingroom_token_cache: Optional[str] = None
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# access_token 管理
|
||||
@@ -123,9 +127,446 @@ class WecomService:
|
||||
logger.error(f"获取 access_token 网络错误: {e}")
|
||||
raise Exception(f"企微API网络错误: {e}") from e
|
||||
|
||||
async def get_contact_access_token(self) -> str:
|
||||
"""获取企微通讯录同步 access_token。
|
||||
|
||||
使用通讯录同步专用 Secret(wecom_contact_secret)获取 access_token,
|
||||
该 Secret 拥有完整的通讯录读取权限(部门列表、成员列表等)。
|
||||
|
||||
当 wecom_contact_secret 未配置时,降级使用普通应用 access_token
|
||||
(此时通讯录相关 API 可能因权限不足返回 errcode 60011)。
|
||||
|
||||
对应企微API:
|
||||
GET https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=ID&corpsecret=CONTACT_SECRET
|
||||
|
||||
Returns:
|
||||
str: access_token 字符串
|
||||
|
||||
Raises:
|
||||
Exception: 获取 access_token 失败
|
||||
"""
|
||||
# 降级:未配置通讯录同步 Secret,使用普通应用 token
|
||||
if not settings.wecom_contact_secret:
|
||||
logger.debug("未配置 wecom_contact_secret,降级使用普通 access_token")
|
||||
return await self.get_access_token()
|
||||
|
||||
# Redis 缓存 key(与应用 token 区分,避免互相覆盖)
|
||||
cache_key = "wecom:contact_access_token"
|
||||
|
||||
# 1. 尝试从 Redis 缓存获取
|
||||
if self.redis:
|
||||
try:
|
||||
cached_token = await self.redis.get(cache_key)
|
||||
if cached_token:
|
||||
logger.debug("从缓存获取通讯录 access_token")
|
||||
return cached_token.decode("utf-8") if isinstance(cached_token, bytes) else cached_token
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 读取通讯录 token 失败(降级): {e}")
|
||||
|
||||
# 1b. 尝试从内存缓存获取
|
||||
if self._contact_token_cache:
|
||||
logger.debug("从内存缓存获取通讯录 access_token")
|
||||
return self._contact_token_cache
|
||||
|
||||
# 2. 缓存未命中,调用企微 API 获取
|
||||
logger.info("缓存未命中,调用企微API获取通讯录 access_token")
|
||||
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
|
||||
params = {
|
||||
"corpid": settings.wecom_corp_id,
|
||||
"corpsecret": settings.wecom_contact_secret,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self.client.get(url, params=params)
|
||||
result = response.json()
|
||||
|
||||
# 检查企微API返回码
|
||||
if result.get("errcode") != 0:
|
||||
error_msg = result.get("errmsg", "未知错误")
|
||||
logger.error(f"获取通讯录 access_token 失败: errcode={result.get('errcode')}, errmsg={error_msg}")
|
||||
raise Exception(f"企微API错误: {error_msg}")
|
||||
|
||||
access_token = result["access_token"]
|
||||
expires_in = result.get("expires_in", 7200)
|
||||
|
||||
# 3. 缓存到 Redis,TTL = 有效期 - 300秒(提前刷新)
|
||||
buffer_seconds = 300
|
||||
cache_ttl = max(expires_in - buffer_seconds, 60) # 至少缓存 60 秒
|
||||
if self.redis:
|
||||
try:
|
||||
await self.redis.setex(cache_key, cache_ttl, access_token)
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 写入通讯录 token 失败(降级): {e}")
|
||||
|
||||
# 3b. 同时缓存到内存
|
||||
self._contact_token_cache = access_token
|
||||
|
||||
logger.info(f"通讯录 access_token 获取成功,缓存 TTL={cache_ttl}秒")
|
||||
return access_token
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"获取通讯录 access_token 网络错误: {e}")
|
||||
raise Exception(f"企微API网络错误: {e}") from e
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 发送文本消息
|
||||
# 会议室 API access_token 管理
|
||||
# --------------------------------------------------------------------------
|
||||
async def get_meetingroom_access_token(self) -> str:
|
||||
"""获取企微会议室 API 专用 access_token。
|
||||
|
||||
企微会议室API需要独立的"会议室"secret获取token,
|
||||
不同于普通应用secret和通讯录secret。
|
||||
|
||||
当 wecom_meetingroom_secret 未配置时,降级使用普通应用 access_token
|
||||
(此时会议室API可能因权限不足返回错误)。
|
||||
|
||||
Redis缓存key: wecom:meetingroom_access_token
|
||||
TTL: 有效期 - 300秒(提前刷新),与 get_access_token() 模式一致。
|
||||
|
||||
Returns:
|
||||
str: access_token 字符串
|
||||
|
||||
Raises:
|
||||
Exception: 获取 access_token 失败
|
||||
"""
|
||||
# 降级:未配置会议室专用 Secret,使用普通应用 token
|
||||
if not settings.wecom_meetingroom_secret:
|
||||
logger.debug("未配置 wecom_meetingroom_secret,降级使用普通 access_token")
|
||||
return await self.get_access_token()
|
||||
|
||||
cache_key = "wecom:meetingroom_access_token"
|
||||
|
||||
# 1. 尝试从 Redis 缓存获取
|
||||
if self.redis:
|
||||
try:
|
||||
cached_token = await self.redis.get(cache_key)
|
||||
if cached_token:
|
||||
logger.debug("从缓存获取会议室 access_token")
|
||||
return cached_token.decode("utf-8") if isinstance(cached_token, bytes) else cached_token
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 读取会议室 token 失败(降级): {e}")
|
||||
|
||||
# 1b. 尝试从内存缓存获取
|
||||
if self._meetingroom_token_cache:
|
||||
logger.debug("从内存缓存获取会议室 access_token")
|
||||
return self._meetingroom_token_cache
|
||||
|
||||
# 2. 缓存未命中,调用企微 API 获取
|
||||
logger.info("缓存未命中,调用企微API获取会议室 access_token")
|
||||
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
|
||||
params = {
|
||||
"corpid": settings.wecom_corp_id,
|
||||
"corpsecret": settings.wecom_meetingroom_secret,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self.client.get(url, params=params)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode") != 0:
|
||||
error_msg = result.get("errmsg", "未知错误")
|
||||
logger.error(f"获取会议室 access_token 失败: errcode={result.get('errcode')}, errmsg={error_msg}")
|
||||
raise Exception(f"企微API错误: {error_msg}")
|
||||
|
||||
access_token = result["access_token"]
|
||||
expires_in = result.get("expires_in", 7200)
|
||||
|
||||
# 3. 缓存到 Redis
|
||||
buffer_seconds = 300
|
||||
cache_ttl = max(expires_in - buffer_seconds, 60)
|
||||
if self.redis:
|
||||
try:
|
||||
await self.redis.setex(cache_key, cache_ttl, access_token)
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 写入会议室 token 失败(降级): {e}")
|
||||
|
||||
# 3b. 同时缓存到内存
|
||||
self._meetingroom_token_cache = access_token
|
||||
|
||||
logger.info(f"会议室 access_token 获取成功,缓存 TTL={cache_ttl}秒")
|
||||
return access_token
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"获取会议室 access_token 网络错误: {e}")
|
||||
raise Exception(f"企微API网络错误: {e}") from e
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 会议室管理 API
|
||||
# --------------------------------------------------------------------------
|
||||
async def get_meetingroom_list(
|
||||
self,
|
||||
city: Optional[str] = None,
|
||||
building: Optional[str] = None,
|
||||
floor: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取会议室列表。
|
||||
|
||||
对应企微API:
|
||||
POST https://qyapi.weixin.qq.com/cgi-bin/oa/meetingroom/list
|
||||
|
||||
Args:
|
||||
city: 城市名称(可选过滤)
|
||||
building: 楼宇名称(可选过滤)
|
||||
floor: 楼层名称(可选过滤)
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 会议室列表,每项含 meetingroom_id/name/capacity/location等
|
||||
|
||||
Raises:
|
||||
Exception: 获取失败
|
||||
"""
|
||||
access_token = await self.get_meetingroom_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/oa/meetingroom/list?access_token={access_token}"
|
||||
|
||||
# 构建请求体(仅包含非空过滤条件)
|
||||
payload: Dict[str, Any] = {}
|
||||
if city:
|
||||
payload["city"] = city
|
||||
if building:
|
||||
payload["building"] = building
|
||||
if floor:
|
||||
payload["floor"] = floor
|
||||
|
||||
try:
|
||||
response = await self.client.post(url, json=payload)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode", 0) != 0:
|
||||
logger.error(
|
||||
f"获取会议室列表失败: errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
|
||||
)
|
||||
raise Exception(f"获取会议室列表失败: {result.get('errmsg')}")
|
||||
|
||||
meetingroom_list = result.get("meetingroom_list", [])
|
||||
logger.info(f"获取会议室列表成功: count={len(meetingroom_list)}")
|
||||
return meetingroom_list
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"获取会议室列表网络错误: {e}")
|
||||
raise Exception(f"获取会议室列表网络错误: {e}") from e
|
||||
|
||||
async def get_booking_info(
|
||||
self,
|
||||
meetingroom_id: int,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取会议室预定状态。
|
||||
|
||||
对应企微API:
|
||||
POST https://qyapi.weixin.qq.com/cgi-bin/oa/meetingroom/get_booking_info
|
||||
|
||||
企微API要求传入时间范围(时间戳),不支持跨天查询。
|
||||
|
||||
Args:
|
||||
meetingroom_id: 企微会议室ID
|
||||
start_time: 查询开始时间(ISO 8601 格式,如 "2026-07-15T00:00:00+08:00")
|
||||
end_time: 查询结束时间(ISO 8601 格式)
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 预定记录列表
|
||||
|
||||
Raises:
|
||||
Exception: 获取失败
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
access_token = await self.get_meetingroom_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/oa/meetingroom/get_booking_info?access_token={access_token}"
|
||||
|
||||
# 将 ISO 8601 时间转为时间戳(秒)
|
||||
start_dt = datetime.fromisoformat(start_time)
|
||||
end_dt = datetime.fromisoformat(end_time)
|
||||
start_ts = int(start_dt.timestamp())
|
||||
end_ts = int(end_dt.timestamp())
|
||||
|
||||
payload = {
|
||||
"meetingroom_id": meetingroom_id,
|
||||
"start_time": start_ts,
|
||||
"end_time": end_ts,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self.client.post(url, json=payload)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode", 0) != 0:
|
||||
logger.error(
|
||||
f"获取预定状态失败: meetingroom_id={meetingroom_id}, "
|
||||
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
|
||||
)
|
||||
raise Exception(f"获取预定状态失败: {result.get('errmsg')}")
|
||||
|
||||
booking_list = result.get("booking_list", [])
|
||||
logger.info(f"获取预定状态成功: meetingroom_id={meetingroom_id}, count={len(booking_list)}")
|
||||
return booking_list
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"获取预定状态网络错误: meetingroom_id={meetingroom_id}, error={e}")
|
||||
raise Exception(f"获取预定状态网络错误: {e}") from e
|
||||
|
||||
async def book_meetingroom(
|
||||
self,
|
||||
meetingroom_id: int,
|
||||
subject: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
booker: str,
|
||||
attendees: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""预定会议室。
|
||||
|
||||
对应企微API:
|
||||
POST https://qyapi.weixin.qq.com/cgi-bin/oa/meetingroom/book
|
||||
|
||||
时间需按30分钟取整(15:15→15:00, 15:45→16:00)。
|
||||
仅可预定无需审批的会议室。
|
||||
|
||||
Args:
|
||||
meetingroom_id: 企微会议室ID
|
||||
subject: 会议主题
|
||||
start_time: 开始时间(ISO 8601 格式)
|
||||
end_time: 结束时间(ISO 8601 格式)
|
||||
booker: 预定人userid
|
||||
attendees: 参与人userid列表(可选)
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 预定结果,含 booking_id
|
||||
|
||||
Raises:
|
||||
Exception: 预定失败
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
access_token = await self.get_meetingroom_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/oa/meetingroom/book?access_token={access_token}"
|
||||
|
||||
# 将 ISO 8601 时间转为时间戳(秒)
|
||||
start_dt = datetime.fromisoformat(start_time)
|
||||
end_dt = datetime.fromisoformat(end_time)
|
||||
start_ts = int(start_dt.timestamp())
|
||||
end_ts = int(end_dt.timestamp())
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"meetingroom_id": meetingroom_id,
|
||||
"subject": subject,
|
||||
"start_time": start_ts,
|
||||
"end_time": end_ts,
|
||||
"booker": booker,
|
||||
}
|
||||
if attendees:
|
||||
payload["attendees"] = attendees
|
||||
|
||||
try:
|
||||
response = await self.client.post(url, json=payload)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode", 0) != 0:
|
||||
logger.error(
|
||||
f"预定会议室失败: meetingroom_id={meetingroom_id}, subject={subject}, "
|
||||
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
|
||||
)
|
||||
raise Exception(f"预定会议室失败: {result.get('errmsg')}")
|
||||
|
||||
booking_id = result.get("booking_id", "")
|
||||
logger.info(f"预定会议室成功: meetingroom_id={meetingroom_id}, booking_id={booking_id}")
|
||||
return result
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"预定会议室网络错误: meetingroom_id={meetingroom_id}, error={e}")
|
||||
raise Exception(f"预定会议室网络错误: {e}") from e
|
||||
|
||||
async def cancel_booking(
|
||||
self,
|
||||
booking_id: str,
|
||||
meetingroom_id: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""取消会议室预定。
|
||||
|
||||
对应企微API:
|
||||
POST https://qyapi.weixin.qq.com/cgi-bin/oa/meetingroom/cancel_book
|
||||
|
||||
Args:
|
||||
booking_id: 企微预定ID
|
||||
meetingroom_id: 企微会议室ID
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 取消结果
|
||||
|
||||
Raises:
|
||||
Exception: 取消失败
|
||||
"""
|
||||
access_token = await self.get_meetingroom_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/oa/meetingroom/cancel_book?access_token={access_token}"
|
||||
|
||||
payload = {
|
||||
"meetingroom_id": meetingroom_id,
|
||||
"booking_id": booking_id,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self.client.post(url, json=payload)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode", 0) != 0:
|
||||
logger.error(
|
||||
f"取消预定失败: booking_id={booking_id}, meetingroom_id={meetingroom_id}, "
|
||||
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
|
||||
)
|
||||
raise Exception(f"取消预定失败: {result.get('errmsg')}")
|
||||
|
||||
logger.info(f"取消预定成功: booking_id={booking_id}, meetingroom_id={meetingroom_id}")
|
||||
return result
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"取消预定网络错误: booking_id={booking_id}, error={e}")
|
||||
raise Exception(f"取消预定网络错误: {e}") from e
|
||||
|
||||
async def get_booking_detail(
|
||||
self,
|
||||
meetingroom_id: int,
|
||||
booking_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""获取预定详情。
|
||||
|
||||
对应企微API:
|
||||
POST https://qyapi.weixin.qq.com/cgi-bin/oa/meetingroom/bookinfo/get
|
||||
|
||||
Args:
|
||||
meetingroom_id: 企微会议室ID
|
||||
booking_id: 企微预定ID
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 预定详情,含 subject/booker/attendees/start_time/end_time
|
||||
|
||||
Raises:
|
||||
Exception: 获取失败
|
||||
"""
|
||||
access_token = await self.get_meetingroom_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/oa/meetingroom/bookinfo/get?access_token={access_token}"
|
||||
|
||||
payload = {
|
||||
"meetingroom_id": meetingroom_id,
|
||||
"booking_id": booking_id,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self.client.post(url, json=payload)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode", 0) != 0:
|
||||
logger.error(
|
||||
f"获取预定详情失败: booking_id={booking_id}, meetingroom_id={meetingroom_id}, "
|
||||
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
|
||||
)
|
||||
raise Exception(f"获取预定详情失败: {result.get('errmsg')}")
|
||||
|
||||
logger.info(f"获取预定详情成功: booking_id={booking_id}")
|
||||
return result
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"获取预定详情网络错误: booking_id={booking_id}, error={e}")
|
||||
raise Exception(f"获取预定详情网络错误: {e}") from e
|
||||
async def send_text_message(
|
||||
self, user_id: str, content: str
|
||||
) -> Dict[str, Any]:
|
||||
@@ -249,6 +690,156 @@ class WecomService:
|
||||
logger.error(f"发送卡片消息网络错误: user_id={user_id}, error={e}")
|
||||
raise Exception(f"发送消息网络错误: {e}") from e
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 发送模板卡片消息 (text_notice)
|
||||
# --------------------------------------------------------------------------
|
||||
async def send_template_card_message(
|
||||
self,
|
||||
user_id: str,
|
||||
main_title: str,
|
||||
main_title_desc: str = "",
|
||||
sub_title_text: str = "",
|
||||
emphasis_title: str = "",
|
||||
emphasis_desc: str = "",
|
||||
jump_list: Optional[List[Dict[str, Any]]] = None,
|
||||
card_action_url: str = "",
|
||||
source_icon: str = "",
|
||||
source_desc: str = "",
|
||||
horizontal_content_list: Optional[List[Dict[str, str]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""向员工发送模板卡片消息(text_notice类型)。
|
||||
|
||||
对应企微API:
|
||||
POST https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=TOKEN
|
||||
|
||||
请求体 (text_notice):
|
||||
{
|
||||
"touser": "UserID",
|
||||
"msgtype": "template_card",
|
||||
"agentid": 1000002,
|
||||
"template_card": {
|
||||
"card_type": "text_notice",
|
||||
"source": {
|
||||
"icon_url": "来源图标URL",
|
||||
"desc": "来源描述"
|
||||
},
|
||||
"main_title": {
|
||||
"title": "一级标题",
|
||||
"desc": "标题辅助信息"
|
||||
},
|
||||
"sub_title_text": "二级普通文本",
|
||||
"emphasis_content": {
|
||||
"title": "关键数据",
|
||||
"desc": "描述"
|
||||
},
|
||||
"horizontal_content_list": [
|
||||
{"keyname": "标题", "value": "内容"}
|
||||
],
|
||||
"jump_list": [
|
||||
{
|
||||
"type": 1,
|
||||
"title": "跳转链接文案",
|
||||
"url": "https://..."
|
||||
}
|
||||
],
|
||||
"card_action": {
|
||||
"type": 1,
|
||||
"url": "https://..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Args:
|
||||
user_id: 员工的企微 UserID
|
||||
main_title: 主标题(一级标题)
|
||||
main_title_desc: 主标题辅助信息
|
||||
sub_title_text: 二级普通文本
|
||||
emphasis_title: 关键数据标题(如剩余时间)
|
||||
emphasis_desc: 关键数据描述
|
||||
jump_list: 跳转按钮列表(每项含 type/title/url)
|
||||
card_action_url: 卡片整体点击跳转链接
|
||||
source_icon: 来源图标URL(可选,如企微agent头像)
|
||||
source_desc: 来源描述(如"IT智能服务台")
|
||||
horizontal_content_list: 关键信息列表 [{"keyname": "标题", "value": "内容"}]
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 企微API返回结果
|
||||
"""
|
||||
access_token = await self.get_access_token()
|
||||
url_api = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
|
||||
|
||||
# 构建 template_card 结构
|
||||
template_card: Dict[str, Any] = {
|
||||
"card_type": "text_notice",
|
||||
"main_title": {
|
||||
"title": main_title,
|
||||
}
|
||||
}
|
||||
|
||||
# 添加来源信息(可选)
|
||||
if source_icon or source_desc:
|
||||
template_card["source"] = {}
|
||||
if source_icon:
|
||||
template_card["source"]["icon_url"] = source_icon
|
||||
if source_desc:
|
||||
template_card["source"]["desc"] = source_desc
|
||||
|
||||
# 添加主标题辅助信息(可选)
|
||||
if main_title_desc:
|
||||
template_card["main_title"]["desc"] = main_title_desc
|
||||
|
||||
# 添加二级普通文本(可选)
|
||||
if sub_title_text:
|
||||
template_card["sub_title_text"] = sub_title_text
|
||||
|
||||
# 添加关键数据高亮(可选)
|
||||
if emphasis_title:
|
||||
template_card["emphasis_content"] = {
|
||||
"title": emphasis_title,
|
||||
}
|
||||
if emphasis_desc:
|
||||
template_card["emphasis_content"]["desc"] = emphasis_desc
|
||||
|
||||
# 添加关键信息列表(可选)
|
||||
if horizontal_content_list:
|
||||
template_card["horizontal_content_list"] = horizontal_content_list
|
||||
|
||||
# 添加跳转按钮列表(可选)
|
||||
if jump_list:
|
||||
template_card["jump_list"] = jump_list
|
||||
|
||||
# 添加卡片整体点击事件(可选)
|
||||
if card_action_url:
|
||||
template_card["card_action"] = {
|
||||
"type": 1, # 跳转URL
|
||||
"url": card_action_url,
|
||||
}
|
||||
|
||||
payload = {
|
||||
"touser": user_id,
|
||||
"msgtype": "template_card",
|
||||
"agentid": int(settings.wecom_agent_id),
|
||||
"template_card": template_card,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self.client.post(url_api, json=payload)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode") != 0:
|
||||
logger.error(
|
||||
f"发送模板卡片消息失败: user_id={user_id}, "
|
||||
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
|
||||
)
|
||||
else:
|
||||
logger.info(f"发送模板卡片消息成功: user_id={user_id}")
|
||||
|
||||
return result
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"发送模板卡片消息网络错误: user_id={user_id}, error={e}")
|
||||
raise Exception(f"发送消息网络错误: {e}") from e
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 发送图片消息
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -436,7 +1027,7 @@ class WecomService:
|
||||
Raises:
|
||||
Exception: 获取失败
|
||||
"""
|
||||
access_token = await self.get_access_token()
|
||||
access_token = await self.get_contact_access_token()
|
||||
url = "https://qyapi.weixin.qq.com/cgi-bin/user/list"
|
||||
params = {
|
||||
"access_token": access_token,
|
||||
@@ -463,6 +1054,60 @@ class WecomService:
|
||||
logger.error(f"获取部门成员网络错误: dept_id={department_id}, error={e}")
|
||||
raise Exception(f"获取部门成员网络错误: {e}") from e
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 获取部门列表
|
||||
# --------------------------------------------------------------------------
|
||||
async def get_department_list(self, department_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
"""获取部门列表。
|
||||
|
||||
对应企微API:
|
||||
GET https://qyapi.weixin.qq.com/cgi-bin/department/list?access_token=TOKEN&id=ID
|
||||
|
||||
返回部门列表,每个部门包含 id(部门ID)、name(部门名称)、parentid(父部门ID)。
|
||||
不传 department_id 时返回全量部门列表。
|
||||
|
||||
用于将 user/list 返回的成员 department ID 列表(如 [1,2])解析为可读的部门名称。
|
||||
|
||||
需要企微通讯录只读权限。
|
||||
|
||||
Args:
|
||||
department_id: 父部门ID(可选)。不传则返回全量部门列表;
|
||||
传入时返回该部门及其子部门。
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 部门列表,每项含 id/name/parentid
|
||||
|
||||
Raises:
|
||||
Exception: 获取失败
|
||||
"""
|
||||
access_token = await self.get_contact_access_token()
|
||||
url = "https://qyapi.weixin.qq.com/cgi-bin/department/list"
|
||||
params: Dict[str, Any] = {
|
||||
"access_token": access_token,
|
||||
}
|
||||
# department_id 为 None 时不传,企微返回全量部门列表
|
||||
if department_id is not None:
|
||||
params["id"] = department_id
|
||||
|
||||
try:
|
||||
response = await self.client.get(url, params=params)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode", 0) != 0:
|
||||
logger.error(
|
||||
f"获取部门列表失败: dept_id={department_id}, "
|
||||
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
|
||||
)
|
||||
raise Exception(f"获取部门列表失败: {result.get('errmsg')}")
|
||||
|
||||
department_list = result.get("department", [])
|
||||
logger.info(f"获取部门列表成功: count={len(department_list)}")
|
||||
return department_list
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"获取部门列表网络错误: dept_id={department_id}, error={e}")
|
||||
raise Exception(f"获取部门列表网络错误: {e}") from e
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# JS-SDK 票据 (v0.5.4:应急页身份检测用)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -51,6 +51,8 @@ class ConnectionManager:
|
||||
self.active_connections: Dict[str, WebSocket] = {}
|
||||
# H5员工连接(employee_id → WebSocket)
|
||||
self.employee_connections: Dict[str, WebSocket] = {}
|
||||
# 终端连接(terminal_sn → WebSocket)— 小鱼易联终端大屏
|
||||
self.terminal_connections: Dict[str, WebSocket] = {}
|
||||
|
||||
# ==========================================================================
|
||||
# 坐席连接管理
|
||||
@@ -305,6 +307,84 @@ class ConnectionManager:
|
||||
|
||||
return sent_count
|
||||
|
||||
# ==========================================================================
|
||||
# 终端连接管理(小鱼易联终端大屏)
|
||||
# ==========================================================================
|
||||
|
||||
async def connect_terminal(self, terminal_sn: str, websocket: WebSocket, subprotocol: str = "") -> None:
|
||||
"""接受终端 WebSocket 握手并注册连接。
|
||||
|
||||
终端WS的token认证可选(无token时仅接收推送,不能发预定指令)。
|
||||
|
||||
如果同一终端重复连接(如页面刷新),旧连接会被覆盖。
|
||||
|
||||
Args:
|
||||
terminal_sn: 终端序列号
|
||||
websocket: FastAPI WebSocket 对象
|
||||
subprotocol: 协商的子协议(如 bearer.{token})
|
||||
"""
|
||||
await websocket.accept(subprotocol=subprotocol if subprotocol else None)
|
||||
|
||||
# 如果该终端已有连接,先关闭旧连接
|
||||
if terminal_sn in self.terminal_connections:
|
||||
old_ws = self.terminal_connections[terminal_sn]
|
||||
try:
|
||||
await old_ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.terminal_connections[terminal_sn] = websocket
|
||||
logger.info(
|
||||
f"终端 WebSocket 连接建立: terminal_sn={terminal_sn}, "
|
||||
f"当前在线终端数={len(self.terminal_connections)}"
|
||||
)
|
||||
|
||||
def disconnect_terminal(self, terminal_sn: str) -> None:
|
||||
"""从终端映射表中移除连接。
|
||||
|
||||
Args:
|
||||
terminal_sn: 终端序列号
|
||||
"""
|
||||
if terminal_sn in self.terminal_connections:
|
||||
del self.terminal_connections[terminal_sn]
|
||||
logger.info(
|
||||
f"终端 WebSocket 连接断开: terminal_sn={terminal_sn}, "
|
||||
f"当前在线终端数={len(self.terminal_connections)}"
|
||||
)
|
||||
|
||||
async def send_to_terminal(self, terminal_sn: str, data: dict) -> None:
|
||||
"""向指定终端发送消息。
|
||||
|
||||
如果发送失败(连接已断开),自动清理该连接。
|
||||
|
||||
Args:
|
||||
terminal_sn: 终端序列号
|
||||
data: 要发送的数据(会被序列化为 JSON)
|
||||
"""
|
||||
websocket = self.terminal_connections.get(terminal_sn)
|
||||
if not websocket:
|
||||
logger.debug(f"终端不在线,跳过推送: terminal_sn={terminal_sn}")
|
||||
return
|
||||
|
||||
try:
|
||||
await websocket.send_json(data)
|
||||
except Exception as e:
|
||||
logger.warning(f"终端 WebSocket 发送失败,清理连接: terminal_sn={terminal_sn}, error={e}")
|
||||
self.disconnect_terminal(terminal_sn)
|
||||
|
||||
async def broadcast_to_terminals(self, terminal_sns: List[str], data: dict) -> None:
|
||||
"""向多个终端批量推送消息。
|
||||
|
||||
Args:
|
||||
terminal_sns: 终端序列号列表
|
||||
data: 要发送的数据
|
||||
"""
|
||||
if not terminal_sns:
|
||||
return
|
||||
|
||||
for sn in terminal_sns:
|
||||
await self.send_to_terminal(sn, data)
|
||||
|
||||
# ==========================================================================
|
||||
# 辅助方法
|
||||
# ==========================================================================
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 小鱼易联终端管理服务
|
||||
# =============================================================================
|
||||
# 说明:封装小鱼易联开放平台API,提供:
|
||||
# 1. API签名生成(支持签名1.0和2.0)
|
||||
# 2. 终端状态查询
|
||||
# 3. 推送自定义页面URL到终端(方案B预留)
|
||||
# 4. 终端列表查询
|
||||
#
|
||||
# 参考:小鱼易联开放平台 https://openapi.xylink.com/
|
||||
# =============================================================================
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class XyLinkService:
|
||||
"""小鱼易联终端管理服务。
|
||||
|
||||
封装小鱼易联开放平台API调用,包括终端状态查询和URL推送。
|
||||
P0阶段终端通过浏览器直接访问URL(方案A),此服务为方案B预留。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
enterprise_id: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
api_base: str = "https://sdk.xylink.com/api/rest/external/v1/",
|
||||
ext_id: str = "",
|
||||
) -> None:
|
||||
"""初始化小鱼易联API客户端。
|
||||
|
||||
Args:
|
||||
enterprise_id: 小鱼易联企业ID
|
||||
client_id: SDK客户端ID
|
||||
client_secret: SDK客户端密钥
|
||||
api_base: API基础URL
|
||||
ext_id: 企业ID(扩展ID)
|
||||
"""
|
||||
self.enterprise_id = enterprise_id
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.api_base = api_base.rstrip("/") + "/"
|
||||
self.ext_id = ext_id
|
||||
# 创建httpx异步客户端
|
||||
self.client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(connect=5.0, read=15.0, write=10.0, pool=5.0)
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 签名生成
|
||||
# ==========================================================================
|
||||
|
||||
def _generate_signature(self, params: Dict[str, Any], timestamp: str) -> str:
|
||||
"""生成API签名(签名2.0 — HMAC-SHA256)。
|
||||
|
||||
小鱼易联API签名算法:
|
||||
1. 将参数按key排序
|
||||
2. 拼接为 key1=value1&key2=value2... 格式
|
||||
3. 追加 ×tamp={timestamp}
|
||||
4. 使用 client_secret 作为密钥,HMAC-SHA256 计算签名
|
||||
|
||||
Args:
|
||||
params: 请求参数
|
||||
timestamp: 时间戳(毫秒)
|
||||
|
||||
Returns:
|
||||
str: 签名字符串(hex)
|
||||
"""
|
||||
# 按key排序并拼接
|
||||
sorted_keys = sorted(params.keys())
|
||||
sign_str = "&".join(f"{k}={params[k]}" for k in sorted_keys)
|
||||
sign_str += f"×tamp={timestamp}"
|
||||
|
||||
# HMAC-SHA256 签名
|
||||
signature = hmac.new(
|
||||
self.client_secret.encode("utf-8"),
|
||||
sign_str.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
return signature
|
||||
|
||||
def _build_headers(self, params: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""构建请求头(含签名认证)。
|
||||
|
||||
Args:
|
||||
params: 请求参数
|
||||
|
||||
Returns:
|
||||
Dict[str, str]: 请求头字典
|
||||
"""
|
||||
import time
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
signature = self._generate_signature(params, timestamp)
|
||||
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"xy-EnterpriseId": self.enterprise_id,
|
||||
"xy-ClientId": self.client_id,
|
||||
"xy-Timestamp": timestamp,
|
||||
"xy-Signature": signature,
|
||||
}
|
||||
|
||||
# ==========================================================================
|
||||
# 终端管理 API
|
||||
# ==========================================================================
|
||||
|
||||
async def get_terminal_status(self, sn: str) -> Dict[str, Any]:
|
||||
"""查询终端在线状态。
|
||||
|
||||
Args:
|
||||
sn: 终端序列号
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 终端状态信息(含 online/status 等字段)
|
||||
|
||||
Raises:
|
||||
Exception: 查询失败
|
||||
"""
|
||||
url = f"{self.api_base}terminal/{sn}/status"
|
||||
params = {"sn": sn}
|
||||
headers = self._build_headers(params)
|
||||
|
||||
try:
|
||||
response = await self.client.get(url, params=params, headers=headers)
|
||||
result = response.json()
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"查询终端状态失败: sn={sn}, status={response.status_code}")
|
||||
raise Exception(f"小鱼易联API错误: HTTP {response.status_code}")
|
||||
|
||||
logger.info(f"查询终端状态成功: sn={sn}")
|
||||
return result
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"查询终端状态网络错误: sn={sn}, error={e}")
|
||||
raise Exception(f"小鱼易联API网络错误: {e}") from e
|
||||
|
||||
async def push_url_to_terminal(self, sn: str, url: str) -> Dict[str, Any]:
|
||||
"""推送自定义页面URL到终端(方案B预留)。
|
||||
|
||||
通过小鱼易联API将指定URL推送到终端设备,
|
||||
终端收到后自动打开该URL(用于远程推送会议室状态页面)。
|
||||
|
||||
Args:
|
||||
sn: 终端序列号
|
||||
url: 要推送的页面URL
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 推送结果
|
||||
|
||||
Raises:
|
||||
Exception: 推送失败
|
||||
"""
|
||||
api_url = f"{self.api_base}terminal/{sn}/push-url"
|
||||
params = {"sn": sn, "url": url}
|
||||
headers = self._build_headers(params)
|
||||
|
||||
try:
|
||||
response = await self.client.post(api_url, json=params, headers=headers)
|
||||
result = response.json()
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"推送URL到终端失败: sn={sn}, status={response.status_code}")
|
||||
raise Exception(f"小鱼易联API错误: HTTP {response.status_code}")
|
||||
|
||||
logger.info(f"推送URL到终端成功: sn={sn}, url={url}")
|
||||
return result
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"推送URL到终端网络错误: sn={sn}, error={e}")
|
||||
raise Exception(f"小鱼易联API网络错误: {e}") from e
|
||||
|
||||
async def list_terminals(self) -> List[Dict[str, Any]]:
|
||||
"""查询终端列表。
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 终端列表,每项含 sn/name/status 等
|
||||
|
||||
Raises:
|
||||
Exception: 查询失败
|
||||
"""
|
||||
url = f"{self.api_base}terminals"
|
||||
params: Dict[str, Any] = {}
|
||||
headers = self._build_headers(params)
|
||||
|
||||
try:
|
||||
response = await self.client.get(url, params=params, headers=headers)
|
||||
result = response.json()
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"查询终端列表失败: status={response.status_code}")
|
||||
raise Exception(f"小鱼易联API错误: HTTP {response.status_code}")
|
||||
|
||||
terminals = result.get("terminals", [])
|
||||
logger.info(f"查询终端列表成功: count={len(terminals)}")
|
||||
return terminals
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"查询终端列表网络错误: {e}")
|
||||
raise Exception(f"小鱼易联API网络错误: {e}") from e
|
||||
|
||||
# ==========================================================================
|
||||
# 资源清理
|
||||
# ==========================================================================
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭 HTTP 客户端连接池。"""
|
||||
await self.client.aclose()
|
||||
logger.info("XyLinkService HTTP 客户端已关闭")
|
||||
Reference in New Issue
Block a user