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:
Simon
2026-07-11 23:13:10 +08:00
parent 3d152fc8eb
commit bea288e414
928 changed files with 85169 additions and 54205 deletions
+1 -21
View File
@@ -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",
]
+346
View File
@@ -0,0 +1,346 @@
# =============================================================================
# IT智能服务台 — 资产数据服务
# =============================================================================
# 说明:从 Excel 资产清单中读取资产信息,用于审批推送时的设备年限核查。
# - 懒加载 Excel(首次查询时才加载文件)
# - 按资产编号查询(遍历所有月度 sheet)
# - 计算使用年限(今天 - 开始使用日期)
# - 生成审批意见文本
#
# Excel 结构:
# - 12 个月度 sheet202501-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
+296
View File
@@ -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 TTL8小时)
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_historyvalue 覆盖,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 小时未恢复的会话,
# 自动标记为 closedclosed_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}")
+26
View File
@@ -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],
+46 -5
View File
@@ -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:
+1 -1
View File
@@ -29,7 +29,7 @@ class FunnyPhraseService:
# 默认话术(当数据库未配置时使用,和 PRD 一致)
DEFAULT_PHRASES = {
"shake": "大哥,俺这就去摇人,稍等...",
"shake": "少主,这就为您去摇人,稍等...",
"keyword": "收到!这就帮您摇位大神来",
"waiting": "人还在路上,别急别急~",
"connected": "人摇来了!IT坐席为您服务",
+260
View File
@@ -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 获取当前坐席的代办工单。
签名认证使用 ITSMSignerSHA1 签名),请求头携带 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,
}
+512
View File
@@ -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
+1 -1
View File
@@ -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(
+35 -11
View File
@@ -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(
+454
View File
@@ -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"
)
# 构建请求 URLbase_url + /v1/chat-messagesDify 原生 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}")
+26 -8
View File
@@ -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}")
+495
View File
@@ -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,
}
+647 -2
View File
@@ -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。
使用通讯录同步专用 Secretwecom_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. 缓存到 RedisTTL = 有效期 - 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:应急页身份检测用)
# --------------------------------------------------------------------------
+80
View File
@@ -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)
# ==========================================================================
# 辅助方法
# ==========================================================================
+218
View File
@@ -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. 追加 &timestamp={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"&timestamp={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 客户端已关闭")