# ============================================================================= # 企微IT智能服务台 — 企微 API 封装服务 # ============================================================================= # 说明:封装所有与企微服务器的交互逻辑,包括: # 1. access_token 管理(Redis 缓存 + 自动刷新) # 2. 发送消息(文本/图片/文件) # 3. 获取员工信息(通讯录 API) # 4. 上传临时素材 # 5. OAuth2 授权换算用户身份 # ============================================================================= import json import logging from typing import Any, Dict, List, Optional import httpx import redis.asyncio as aioredis from app.config import settings logger = logging.getLogger(__name__) class WecomService: """企微 API 调用服务。 封装所有与企微服务器的 HTTP 交互,提供异步方法。 access_token 通过 Redis 缓存管理,避免频繁调用获取接口。 Attributes: redis: Redis 异步客户端(用于缓存 access_token) client: httpx 异步 HTTP 客户端 """ def __init__(self, redis_client: Optional[aioredis.Redis] = None): """初始化企微服务。 Args: redis_client: Redis 异步客户端实例(可为 None,本地开发时 Redis 不可用) """ self.redis = redis_client # 创建 httpx 异步客户端 # timeout: 连接超时5秒,读取超时10秒 self.client = httpx.AsyncClient( timeout=httpx.Timeout(connect=5.0, read=10.0, write=10.0, pool=5.0) ) # 内存缓存(Redis 不可用时的降级方案) self._token_cache: Optional[str] = None # 通讯录同步 token 的内存缓存(与应用 token 区分,避免互相覆盖) self._contact_token_cache: Optional[str] = None # 会议室 API token 的内存缓存(使用独立 secret,与应用/通讯录 token 区分) self._meetingroom_token_cache: Optional[str] = None # -------------------------------------------------------------------------- # access_token 管理 # -------------------------------------------------------------------------- async def get_access_token(self) -> str: """获取企微 access_token。 优先从 Redis 缓存获取,如果缓存不存在或即将过期则重新获取。 access_token 有效期 7200 秒,缓存 TTL 设为 6900 秒(提前 300 秒刷新)。 对应企微API: GET https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=ID&corpsecret=SECRET Returns: str: access_token 字符串 Raises: Exception: 获取 access_token 失败 """ # Redis 缓存 key cache_key = "wecom:access_token" # 1. 尝试从 Redis 缓存获取 if self.redis: try: cached_token = await self.redis.get(cache_key) if cached_token: logger.debug("从缓存获取 access_token") return cached_token.decode("utf-8") except Exception as e: logger.warning(f"Redis 读取失败(降级): {e}") # 1b. 尝试从内存缓存获取 if self._token_cache: logger.debug("从内存缓存获取 access_token") return self._token_cache # 2. 缓存未命中,调用企微 API 获取 logger.info("缓存未命中,调用企微API获取 access_token") url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken" params = { "corpid": settings.wecom_corp_id, "corpsecret": settings.wecom_secret, } try: response = await self.client.get(url, params=params) result = response.json() # 检查企微API返回码 if result.get("errcode") != 0: error_msg = result.get("errmsg", "未知错误") logger.error(f"获取 access_token 失败: errcode={result.get('errcode')}, errmsg={error_msg}") raise Exception(f"企微API错误: {error_msg}") access_token = result["access_token"] expires_in = result.get("expires_in", 7200) # 3. 缓存到 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") return cached.decode("utf-8") except Exception as e: logger.warning(f"Redis 读取 jsapi_ticket 失败(降级): {e}") # 2. 调用企微 API access_token = await self.get_access_token() url = f"https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket?access_token={access_token}" try: response = await self.client.get(url) result = response.json() if result.get("errcode", 0) != 0: logger.error( f"获取 jsapi_ticket 失败: " f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}" ) raise Exception(f"获取 jsapi_ticket 失败: {result.get('errmsg')}") ticket = result.get("ticket", "") expires_in = result.get("expires_in", 7200) # 3. 缓存到 Redis(TTL = expires_in - 300s) cache_ttl = max(expires_in - 300, 60) if self.redis: try: await self.redis.setex(cache_key, cache_ttl, ticket) except Exception as e: logger.warning(f"Redis 写入 jsapi_ticket 失败(降级): {e}") logger.info(f"jsapi_ticket 获取成功,缓存 TTL={cache_ttl}秒") return ticket except httpx.HTTPError as e: logger.error(f"获取 jsapi_ticket 网络错误: {e}") raise Exception(f"企微API网络错误: {e}") from e async def get_agent_config_ticket(self) -> str: """获取企微 agent_config_ticket。 对应企微API: GET https://qyapi.weixin.qq.com/cgi-bin/ticket/get?type=agent_config&access_token=TOKEN agent_config_ticket 用于 wx.agentConfig() 的签名计算。 与 jsapi_ticket 是不同的票据,不能混用。 有效期 7200 秒,缓存到 Redis(提前 300 秒刷新)。 Returns: str: agent_config_ticket 字符串 Raises: Exception: 获取失败 """ cache_key = "wecom:agent_config_ticket" # 1. Redis 缓存 if self.redis: try: cached = await self.redis.get(cache_key) if cached: if isinstance(cached, bytes): cached = cached.decode("utf-8") logger.debug("从缓存获取 agent_config_ticket") return cached except Exception as e: logger.warning(f"Redis 读取 agent_config_ticket 失败(降级): {e}") # 2. 调用企微 API access_token = await self.get_access_token() url = ( f"https://qyapi.weixin.qq.com/cgi-bin/ticket/get" f"?type=agent_config&access_token={access_token}" ) try: response = await self.client.get(url) result = response.json() if result.get("errcode", 0) != 0: logger.error( f"获取 agent_config_ticket 失败: " f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}" ) raise Exception( f"获取 agent_config_ticket 失败: {result.get('errmsg')}" ) ticket = result.get("ticket", "") expires_in = result.get("expires_in", 7200) # 3. 缓存到 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 客户端已关闭")