Files
wecom_it_smart_desk/backend/app/services/wecom_service.py
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

1388 lines
52 KiB
Python
Raw Permalink 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智能服务台 — 企微 API 封装服务
# =============================================================================
# 说明:封装所有与企微服务器的交互逻辑,包括:
# 1. access_token 管理(Redis 缓存 + 自动刷新)
# 2. 发送消息(文本/图片/文件)
# 3. 获取员工信息(通讯录 API)
# 4. 上传临时素材
# 5. OAuth2 授权换算用户身份
# =============================================================================
import json
import logging
from typing import Any, Dict, List, Optional
import httpx
import redis.asyncio as aioredis
from app.config import settings
logger = logging.getLogger(__name__)
class WecomService:
"""企微 API 调用服务。
封装所有与企微服务器的 HTTP 交互,提供异步方法。
access_token 通过 Redis 缓存管理,避免频繁调用获取接口。
Attributes:
redis: Redis 异步客户端(用于缓存 access_token
client: httpx 异步 HTTP 客户端
"""
def __init__(self, redis_client: Optional[aioredis.Redis] = None):
"""初始化企微服务。
Args:
redis_client: Redis 异步客户端实例(可为 None,本地开发时 Redis 不可用)
"""
self.redis = redis_client
# 创建 httpx 异步客户端
# timeout: 连接超时5秒,读取超时10秒
self.client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=5.0, read=10.0, write=10.0, pool=5.0)
)
# 内存缓存(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 管理
# --------------------------------------------------------------------------
async def get_access_token(self) -> str:
"""获取企微 access_token。
优先从 Redis 缓存获取,如果缓存不存在或即将过期则重新获取。
access_token 有效期 7200 秒,缓存 TTL 设为 6900 秒(提前 300 秒刷新)。
对应企微API:
GET https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=ID&corpsecret=SECRET
Returns:
str: access_token 字符串
Raises:
Exception: 获取 access_token 失败
"""
# Redis 缓存 key
cache_key = "wecom: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")
except Exception as e:
logger.warning(f"Redis 读取失败(降级): {e}")
# 1b. 尝试从内存缓存获取
if self._token_cache:
logger.debug("从内存缓存获取 access_token")
return self._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_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 写入失败(降级): {e}")
# 3b. 同时缓存到内存
self._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
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]:
"""向员工发送文本消息。
对应企微API:
POST https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=TOKEN
请求体:
{
"touser": "UserID",
"msgtype": "text",
"agentid": 1000002,
"text": {"content": "消息内容"}
}
Args:
user_id: 员工的企微 UserID
content: 消息内容(纯文本)
Returns:
Dict[str, Any]: 企微API返回结果
"""
access_token = await self.get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
payload = {
"touser": user_id,
"msgtype": "text",
"agentid": int(settings.wecom_agent_id),
"text": {"content": content},
}
try:
response = await self.client.post(url, 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
# --------------------------------------------------------------------------
# 发送卡片消息
# --------------------------------------------------------------------------
async def send_card_message(
self,
user_id: str,
title: str,
description: str,
url: str = "",
btntxt: str = "详情",
) -> Dict[str, Any]:
"""向员工发送文本卡片消息。
对应企微API:
POST https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=TOKEN
请求体:
{
"touser": "UserID",
"msgtype": "textcard",
"agentid": 1000002,
"textcard": {
"title": "标题",
"description": "描述",
"url": "链接",
"btntxt": "按钮文字"
}
}
Args:
user_id: 员工的企微 UserID
title: 卡片标题
description: 卡片描述
url: 卡片点击跳转链接
btntxt: 按钮文字(默认"详情"
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}"
payload = {
"touser": user_id,
"msgtype": "textcard",
"agentid": int(settings.wecom_agent_id),
"textcard": {
"title": title,
"description": description,
"url": url,
"btntxt": btntxt,
},
}
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
# --------------------------------------------------------------------------
# 发送模板卡片消息 (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
# --------------------------------------------------------------------------
# 发送图片消息
# --------------------------------------------------------------------------
async def send_image_message(
self, user_id: str, media_id: str
) -> Dict[str, Any]:
"""向员工发送图片消息。
对应企微API:
POST https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=TOKEN
请求体:
{
"touser": "UserID",
"msgtype": "image",
"agentid": 1000002,
"image": {"media_id": "MEDIA_ID"}
}
注意:发送图片前需要先通过 upload_temp_media 上传图片获取 media_id。
Args:
user_id: 员工的企微 UserID
media_id: 图片媒体ID(通过上传临时素材获取)
Returns:
Dict[str, Any]: 企微API返回结果
"""
access_token = await self.get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
payload = {
"touser": user_id,
"msgtype": "image",
"agentid": int(settings.wecom_agent_id),
"image": {"media_id": media_id},
}
try:
response = await self.client.post(url, 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
# --------------------------------------------------------------------------
# 发送文件消息
# --------------------------------------------------------------------------
async def send_file_message(
self, user_id: str, media_id: str
) -> Dict[str, Any]:
"""向员工发送文件消息。
对应企微API:
POST https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=TOKEN
请求体:
{
"touser": "UserID",
"msgtype": "file",
"agentid": 1000002,
"file": {"media_id": "MEDIA_ID"}
}
注意:发送文件前需要先通过 upload_temp_media 上传文件获取 media_id。
Args:
user_id: 员工的企微 UserID
media_id: 文件媒体ID(通过上传临时素材获取)
Returns:
Dict[str, Any]: 企微API返回结果
"""
access_token = await self.get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
payload = {
"touser": user_id,
"msgtype": "file",
"agentid": int(settings.wecom_agent_id),
"file": {"media_id": media_id},
}
try:
response = await self.client.post(url, 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
# --------------------------------------------------------------------------
# 获取员工通讯录信息
# --------------------------------------------------------------------------
async def get_user_info(self, user_id: str) -> Dict[str, Any]:
"""获取员工通讯录详细信息(用于 VIP 判断)。
对应企微API:
GET https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token=TOKEN&userid=USERID
返回数据包含:
- userid: 员工UserID
- name: 员工姓名
- department: 部门ID列表
- position: 岗位
- mobile: 手机号
- email: 邮箱
- status: 激活状态
需要企微通讯录只读权限。
Args:
user_id: 员工的企微 UserID
Returns:
Dict[str, Any]: 员工信息字典
Raises:
Exception: 获取失败
"""
access_token = await self.get_access_token()
url = "https://qyapi.weixin.qq.com/cgi-bin/user/get"
params = {
"access_token": access_token,
"userid": user_id,
}
try:
response = await self.client.get(url, params=params)
result = response.json()
if result.get("errcode", 0) != 0:
logger.error(
f"获取员工信息失败: user_id={user_id}, "
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
)
raise Exception(f"获取员工信息失败: {result.get('errmsg')}")
logger.info(f"获取员工信息成功: user_id={user_id}, name={result.get('name', '')}")
return result
except httpx.HTTPError as e:
logger.error(f"获取员工信息网络错误: user_id={user_id}, error={e}")
raise Exception(f"获取员工信息网络错误: {e}") from e
# --------------------------------------------------------------------------
# 获取部门成员列表
# --------------------------------------------------------------------------
async def get_department_members(
self, department_id: int = 1, fetch_child: int = 1
) -> List[Dict[str, Any]]:
"""获取部门成员列表。
对应企微API:
GET https://qyapi.weixin.qq.com/cgi-bin/user/list?access_token=TOKEN&department_id=ID&fetch_child=1
Args:
department_id: 部门ID(默认1为根部门)
fetch_child: 是否递归获取子部门(1=是, 0=否)
Returns:
List[Dict[str, Any]]: 部门成员列表
Raises:
Exception: 获取失败
"""
access_token = await self.get_contact_access_token()
url = "https://qyapi.weixin.qq.com/cgi-bin/user/list"
params = {
"access_token": access_token,
"department_id": department_id,
"fetch_child": fetch_child,
}
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')}")
userlist = result.get("userlist", [])
logger.info(f"获取部门成员成功: dept_id={department_id}, count={len(userlist)}")
return userlist
except httpx.HTTPError as e:
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:应急页身份检测用)
# --------------------------------------------------------------------------
async def get_jsapi_ticket(self) -> str:
"""获取企微 JS-SDK 票据 jsapi_ticket。
对应企微API:
GET https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket?access_token=TOKEN
jsapi_ticket 用于计算 JS-SDK 签名(sha1),让前端 wx.config/wx.agentConfig 鉴权通过。
有效期 7200 秒,缓存到 Redis(提前 300 秒刷新)。
Returns:
str: jsapi_ticket 字符串
Raises:
Exception: 获取失败
"""
cache_key = "wecom:jsapi_ticket"
# 1. Redis 缓存
if self.redis:
try:
cached = await self.redis.get(cache_key)
if cached:
logger.debug("从缓存获取 jsapi_ticket")
return cached.decode("utf-8")
except Exception as e:
logger.warning(f"Redis 读取 jsapi_ticket 失败(降级): {e}")
# 2. 调用企微 API
access_token = await self.get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket?access_token={access_token}"
try:
response = await self.client.get(url)
result = response.json()
if result.get("errcode", 0) != 0:
logger.error(
f"获取 jsapi_ticket 失败: "
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
)
raise Exception(f"获取 jsapi_ticket 失败: {result.get('errmsg')}")
ticket = result.get("ticket", "")
expires_in = result.get("expires_in", 7200)
# 3. 缓存到 Redis(TTL = expires_in - 300s)
cache_ttl = max(expires_in - 300, 60)
if self.redis:
try:
await self.redis.setex(cache_key, cache_ttl, ticket)
except Exception as e:
logger.warning(f"Redis 写入 jsapi_ticket 失败(降级): {e}")
logger.info(f"jsapi_ticket 获取成功,缓存 TTL={cache_ttl}")
return ticket
except httpx.HTTPError as e:
logger.error(f"获取 jsapi_ticket 网络错误: {e}")
raise Exception(f"企微API网络错误: {e}") from e
async def get_agent_config_ticket(self) -> str:
"""获取企微 agent_config_ticket。
对应企微API:
GET https://qyapi.weixin.qq.com/cgi-bin/ticket/get?type=agent_config&access_token=TOKEN
agent_config_ticket 用于 wx.agentConfig() 的签名计算。
与 jsapi_ticket 是不同的票据,不能混用。
有效期 7200 秒,缓存到 Redis(提前 300 秒刷新)。
Returns:
str: agent_config_ticket 字符串
Raises:
Exception: 获取失败
"""
cache_key = "wecom:agent_config_ticket"
# 1. Redis 缓存
if self.redis:
try:
cached = await self.redis.get(cache_key)
if cached:
if isinstance(cached, bytes):
cached = cached.decode("utf-8")
logger.debug("从缓存获取 agent_config_ticket")
return cached
except Exception as e:
logger.warning(f"Redis 读取 agent_config_ticket 失败(降级): {e}")
# 2. 调用企微 API
access_token = await self.get_access_token()
url = (
f"https://qyapi.weixin.qq.com/cgi-bin/ticket/get"
f"?type=agent_config&access_token={access_token}"
)
try:
response = await self.client.get(url)
result = response.json()
if result.get("errcode", 0) != 0:
logger.error(
f"获取 agent_config_ticket 失败: "
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
)
raise Exception(
f"获取 agent_config_ticket 失败: {result.get('errmsg')}"
)
ticket = result.get("ticket", "")
expires_in = result.get("expires_in", 7200)
# 3. 缓存到 RedisTTL = expires_in - 300s
cache_ttl = max(expires_in - 300, 60)
if self.redis:
try:
await self.redis.setex(cache_key, cache_ttl, ticket)
except Exception as e:
logger.warning(
f"Redis 写入 agent_config_ticket 失败(降级): {e}"
)
logger.info(
f"agent_config_ticket 获取成功,缓存 TTL={cache_ttl}"
)
return ticket
except httpx.HTTPError as e:
logger.error(f"获取 agent_config_ticket 网络错误: {e}")
raise Exception(f"企微API网络错误: {e}") from e
@staticmethod
def generate_jsapi_signature(
ticket: str, nonce_str: str, timestamp: int, url: str
) -> str:
"""生成 JS-SDK 签名(sha1)。
对应企微JS-SDK签名算法:
1. 拼接:jsapi_ticket={ticket}&noncestr={nonce_str}&timestamp={timestamp}&url={url}
2. sha1(拼接字符串)
注意:
- url 不含 # 及其后面部分
- url 不含 ?
- url 是前端调用 wx.config 的页面 URL
- 此方法同时用于 jsapi_ticket 和 agent_config_ticket 的签名计算
(签名算法相同,只是 ticket 不同)
Args:
ticket: jsapi_ticket 或 agent_config_ticket
nonce_str: 随机字符串(前端生成,16位)
timestamp: 当前时间戳(秒)
url: 当前页面 URL(不含 # 后面)
Returns:
str: sha1 签名字符串(40 字符)
"""
import hashlib
# 拼接签名字符串
raw = f"jsapi_ticket={ticket}&noncestr={nonce_str}&timestamp={timestamp}&url={url}"
# sha1 哈希
signature = hashlib.sha1(raw.encode("utf-8")).hexdigest()
return signature
# --------------------------------------------------------------------------
# 上传临时素材
# --------------------------------------------------------------------------
async def upload_temp_media(
self, media_type: str, file_data: bytes, filename: str = "upload"
) -> str:
"""上传临时素材(图片/文件/语音),获取 media_id。
对应企微API:
POST https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token=TOKEN&type=TYPE
临时素材有效期 3 天,适用于发送图片/文件消息。
Args:
media_type: 媒体类型(image/file/voice
file_data: 文件二进制数据
filename: 文件名
Returns:
str: media_id(用于发送图片/文件消息时引用)
Raises:
Exception: 上传失败
"""
access_token = await self.get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token={access_token}&type={media_type}"
try:
# 使用 multipart 上传文件
files = {"media": (filename, file_data)}
response = await self.client.post(url, files=files)
result = response.json()
if result.get("errcode", 0) != 0:
logger.error(
f"上传临时素材失败: type={media_type}, "
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
)
raise Exception(f"上传临时素材失败: {result.get('errmsg')}")
media_id = result.get("media_id", "")
logger.info(f"上传临时素材成功: type={media_type}, media_id={media_id}")
return media_id
except httpx.HTTPError as e:
logger.error(f"上传临时素材网络错误: type={media_type}, error={e}")
raise Exception(f"上传临时素材网络错误: {e}") from e
# --------------------------------------------------------------------------
# OAuth2 授权换算用户身份
# --------------------------------------------------------------------------
async def get_oauth_user_info(self, code: str) -> Dict[str, str]:
"""通过 OAuth2 授权码换取员工身份信息。
对应企微API:
GET https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token=TOKEN&code=CODE
H5 页面通过企微 OAuth2 静默授权获取 code,后端用 code 换取员工 UserID。
适用于 H5 用户端身份识别。
Args:
code: 企微 OAuth2 授权码
Returns:
Dict[str, str]: 包含 userid 和 user_ticket 的字典
Raises:
Exception: 换取失败
"""
access_token = await self.get_access_token()
url = "https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo"
params = {
"access_token": access_token,
"code": code,
}
try:
response = await self.client.get(url, params=params)
result = response.json()
if result.get("errcode", 0) != 0:
logger.error(
f"OAuth2换取用户身份失败: code={code}, "
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
)
raise Exception(f"OAuth2换取用户身份失败: {result.get('errmsg')}")
user_id = result.get("userid", "")
logger.info(f"OAuth2换取用户身份成功: userid={user_id}")
return {
"userid": user_id,
"user_ticket": result.get("user_ticket", ""),
}
except httpx.HTTPError as e:
logger.error(f"OAuth2换取用户身份网络错误: code={code}, error={e}")
raise Exception(f"OAuth2换取用户身份网络错误: {e}") from e
# --------------------------------------------------------------------------
# 关闭客户端
# --------------------------------------------------------------------------
async def close(self) -> None:
"""关闭 HTTP 客户端连接池。
应用关闭时调用,释放资源。
"""
await self.client.aclose()
logger.info("WecomService HTTP 客户端已关闭")