2026-06-14 16:49:18 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 企微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
|
2026-07-11 23:13:10 +08:00
|
|
|
|
# 通讯录同步 token 的内存缓存(与应用 token 区分,避免互相覆盖)
|
|
|
|
|
|
self._contact_token_cache: Optional[str] = None
|
|
|
|
|
|
# 会议室 API token 的内存缓存(使用独立 secret,与应用/通讯录 token 区分)
|
|
|
|
|
|
self._meetingroom_token_cache: Optional[str] = None
|
2026-06-14 16:49:18 +08:00
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# 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. 缓存到 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
|
|
|
|
|
|
|
2026-07-11 23:13:10 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
# --------------------------------------------------------------------------
|
2026-07-11 23:13:10 +08:00
|
|
|
|
# 会议室 API access_token 管理
|
2026-06-14 16:49:18 +08:00
|
|
|
|
# --------------------------------------------------------------------------
|
2026-07-11 23:13:10 +08:00
|
|
|
|
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
|
2026-06-14 16:49:18 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-11 23:13:10 +08:00
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# 发送模板卡片消息 (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
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# 发送图片消息
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
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: 获取失败
|
|
|
|
|
|
"""
|
2026-07-11 23:13:10 +08:00
|
|
|
|
access_token = await self.get_contact_access_token()
|
2026-06-14 16:49:18 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-11 23:13:10 +08:00
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# 获取部门列表
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-06-16 10:07:42 +08:00
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
2026-07-13 02:17:03 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-06-16 10:07:42 +08:00
|
|
|
|
@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
|
2026-07-13 02:17:03 +08:00
|
|
|
|
- 此方法同时用于 jsapi_ticket 和 agent_config_ticket 的签名计算
|
|
|
|
|
|
(签名算法相同,只是 ticket 不同)
|
2026-06-16 10:07:42 +08:00
|
|
|
|
|
|
|
|
|
|
Args:
|
2026-07-13 02:17:03 +08:00
|
|
|
|
ticket: jsapi_ticket 或 agent_config_ticket
|
2026-06-16 10:07:42 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# 上传临时素材
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-17 23:08:59 +08:00
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# 下载临时素材
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# 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 客户端已关闭")
|