Files
wecom_it_smart_desk/backend/app/services/todo_source_service.py
T
Simon bea288e414 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
2026-07-11 23:13:10 +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 = 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,
}