1438 lines
54 KiB
Python
1438 lines
54 KiB
Python
# =============================================================================
|
||
# 企微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")
|
||
# v4.0 C8 修复:decode_responses=True 时返回 str,兼容 bytes
|
||
return cached_token if isinstance(cached_token, str) else 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. 缓存到 Redis,TTL = 有效期 - 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。
|
||
|
||
使用通讯录同步专用 Secret(wecom_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. 缓存到 Redis,TTL = 有效期 - 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")
|
||
# v4.0 C8 修复:兼容 str/bytes
|
||
return cached if isinstance(cached, str) else 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. 缓存到 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 写入 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}×tamp={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}×tamp={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
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 下载临时素材
|
||
# --------------------------------------------------------------------------
|
||
async def download_temp_media(self, media_id: str) -> bytes:
|
||
"""下载临时素材(图片/文件/语音),返回二进制数据。
|
||
|
||
对应企微API:
|
||
GET https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token=TOKEN&media_id=MEDIA_ID
|
||
|
||
用于将企微回调中的图片/文件下载到本地服务器保存。
|
||
|
||
Args:
|
||
media_id: 媒体文件ID(企微回调中的 MediaId)
|
||
|
||
Returns:
|
||
bytes: 媒体文件的二进制数据
|
||
|
||
Raises:
|
||
Exception: 下载失败
|
||
"""
|
||
access_token = await self.get_access_token()
|
||
url = "https://qyapi.weixin.qq.com/cgi-bin/media/get"
|
||
params = {
|
||
"access_token": access_token,
|
||
"media_id": media_id,
|
||
}
|
||
|
||
try:
|
||
logger.info(f"开始下载临时素材: media_id={media_id}")
|
||
response = await self.client.get(url, params=params)
|
||
|
||
# 检查返回的是否是JSON错误响应
|
||
content_type = response.headers.get("content-type", "")
|
||
if "application/json" in content_type:
|
||
result = response.json()
|
||
if result.get("errcode") != 0:
|
||
errmsg = result.get("errmsg", "未知错误")
|
||
logger.error(f"下载临时素材失败: media_id={media_id}, errcode={result.get('errcode')}, errmsg={errmsg}")
|
||
raise Exception(f"下载临时素材失败: {errmsg}")
|
||
|
||
# 返回二进制数据
|
||
logger.info(f"下载临时素材成功: media_id={media_id}, size={len(response.content)} bytes")
|
||
return response.content
|
||
|
||
except httpx.HTTPError as e:
|
||
logger.error(f"下载临时素材网络错误: media_id={media_id}, 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 客户端已关闭")
|