Files
wecom_it_smart_desk/backend/app/services/wecom_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

1314 lines
49 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智能服务台 — 企微 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
@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
Args:
ticket: jsapi_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 客户端已关闭")