Files
wecom_it_smart_desk/backend/app/services/todo_source_service.py
T
Simon 449c6d4875 feat: 2026-07-12~13 全量更新 - AI对话链路改造+H5 v4/v5+坐席端v5+上下文感知诊断+知识库迭代3
## H5 员工端 v4 (2026-07-13 00:48 已部署)
- 人工按钮三态文案统一为"人工坐席"
- 按钮位置移至发送键和语音按钮上方(垂直堆叠)
- 点按钮直接调 store.shakeAgent(),删除 CallAgentModal 弹窗动画
- 截图快捷键提示改为"截图->粘贴:Alt+Shift+A-Ctrl+V ---> Ctrl+V"
- 移动端隐藏截图提示(CSS 媒体查询)
- AI转人工提示改为"已为您呼叫人工坐席,请稍等!"
- 坐席接入提示改为"坐席正在查看您的信息,请等待处理回复!"
- 删除"摇铃呼叫坐席"入口和文案
- 删除孤儿组件 MessageList.vue + shake 动画 CSS

## H5 员工端 v5 (2026-07-13 02:08 已部署)
- RightPanel v2.1:删除"软件安装"和"资源权限"标签页
- 移除标签栏,智能推荐(DynamicRecommend)直接展示
- 删除 SoftwareDownloads/ApprovalLinks 引用和相关 CSS

## AI 对话链路全栈改造 Phase 1-6 (已部署)
- Phase 1: Dify JSON输出 + 后端blocking解析 + 双WS推送 + 错误降级
- Phase 2: 关键词收窄(~25强意图词) + 两级分类Prompt + 删除前端checkApprovalIntent
- Phase 3: WS扩展(ai_thinking+dynamic_recommend) + ai_structured气泡 + RightPanel v2 + 选项回传
- Phase 4: VisionService接入 + 图片消息融合(5秒窗口) + 降级策略
- Phase 5: 坐席端ai_thinking指示器 + ai_structured/byod_card渲染 + handleNewMessage修复
- Phase 6: diagnosis_stage(6值) + response_time_ms计时 + 慢响应告警(>10s)

## 坐席端 v5 (2026-07-13 01:38 已部署)
- ai_structured/byod_card 只读渲染
- AI思考指示器 UI
- handleNewMessage 透传 msg_type/extra_data 修复
- 布局优化v2.0: QuickReplyBar L1+L2悬浮 + ReplyBox左右分区 + 右栏260/560px切换
- 键盘快捷键v2.3: 纯数字路由 + ESC分层撤销 + Shift+Space用event.code

## 上下文感知智能诊断闭环 (2026-07-12 已部署)
- 三层诊断(API→Script→AI) + 三段排队(VIP→info_locked→not locked)
- 答题插队 + 五场景关闭
- 迁移052(6表+6列) + queue_service + quiz_service + closing_service
- H5前端: QueueWaiting + RightPanel双Tab + InputBar三态 + ResolveConfirmCard
- 坐席前端: pending_close结单流程 + 信息锁定(Dify步骤完成+有效回答率≥70%)

## 知识库迭代3 (2026-07-12 已部署)
- 分诊交互(H5+坐席+Dify独立应用)
- 拓扑预览(ECharts只读)
- 代答排除(4种匹配器: keyword/regex/intent/category)
- 迁移051 + 44文件43测试通过

## 后端变更
- 6个Python文件改造(h5_ai_task.py/h5.py/ai_service.py/closing_service.py等)
- funny_phrase_service.py: shake/connected/keyword 默认文案更新
- session_service.py: 企微消息文案同步
- 新增: queue.py/quiz.py/triage.py/exclusion_rules.py 等API端点
- 新增: diagnostic.py/quiz.py/triage_session.py 等模型
- 新增: closing_service/queue_service/quiz_service/triage_service 等服务

## 文档更新
- CHANGELOG.md: 新增 [未发布] 区全部变更记录
- 项目管理主文档 v2.5: 新增v0.7.3版本 + 已完成看板 + 最近搞定
- 版本记录: 新增v0.7.3条目
- AI对话链路实施计划: Phase 1-6 全部标记已实施
- 新增架构图/时序图/类图(mermaid)

## 部署路径修正
- 服务器项目根路径: /opt/wecom-it-desk/
- 所有前端dist均为ro bind mount,只能在宿主机源路径操作
- 服务器nginx /h5/ 是静态文件服务(非proxy_pass)
- elFinder上传二进制不可靠(MD5不匹配),改用base64分块上传
2026-07-13 02:17:03 +08:00

496 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================================
# 企微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 = 30
# 企微 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,
}