bugfix
This commit is contained in:
parent
54b41c7160
commit
ba211de526
234
woa/media.py
234
woa/media.py
@ -21,75 +21,75 @@ from typing import Optional, Dict, Any, Union
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
class WeChatMediaManager:
|
class WeChatMediaManager:
|
||||||
def __init__(self, app_id: str, app_secret: str):
|
def __init__(self, app_id: str, app_secret: str):
|
||||||
self.app_id = app_id
|
self.app_id = app_id
|
||||||
self.app_secret = app_secret
|
self.app_secret = app_secret
|
||||||
self._access_token: Optional[str] = None
|
self._access_token: Optional[str] = None
|
||||||
self._token_expires_at: float = 0
|
self._token_expires_at: float = 0
|
||||||
self._token_lock = asyncio.Lock()
|
self._token_lock = asyncio.Lock()
|
||||||
|
|
||||||
# ================= 1. Token 管理 (基础) =================
|
# ================= 1. Token 管理 (基础) =================
|
||||||
async def get_access_token(self) -> str:
|
async def get_access_token(self) -> str:
|
||||||
"""
|
"""
|
||||||
获取 access_token,带内存缓存和锁机制,防止高并发下重复刷新
|
获取 access_token,带内存缓存和锁机制,防止高并发下重复刷新
|
||||||
"""
|
"""
|
||||||
# 如果 token 有效时间大于 200 秒,直接返回缓存
|
# 如果 token 有效时间大于 200 秒,直接返回缓存
|
||||||
if self._access_token and time.time() < self._token_expires_at - 20:
|
if self._access_token and time.time() < self._token_expires_at - 20:
|
||||||
return self._access_token
|
return self._access_token
|
||||||
|
|
||||||
async with self._token_lock:
|
async with self._token_lock:
|
||||||
# 双重检查锁
|
# 双重检查锁
|
||||||
if self._access_token and time.time() < self._token_expires_at - 20:
|
if self._access_token and time.time() < self._token_expires_at - 20:
|
||||||
return self._access_token
|
return self._access_token
|
||||||
|
|
||||||
url = "https://api.weixin.qq.com/cgi-bin/token"
|
url = "https://api.weixin.qq.com/cgi-bin/token"
|
||||||
params = {
|
params = {
|
||||||
"grant_type": "client_credential",
|
"grant_type": "client_credential",
|
||||||
"appid": self.app_id,
|
"appid": self.app_id,
|
||||||
"secret": self.app_secret
|
"secret": self.app_secret
|
||||||
}
|
}
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.get(url, params=params) as resp:
|
async with session.get(url, params=params) as resp:
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
|
|
||||||
if "access_token" in data:
|
if "access_token" in data:
|
||||||
self._access_token = data["access_token"]
|
self._access_token = data["access_token"]
|
||||||
expires_in = data.get("expires_in", 7200)
|
expires_in = data.get("expires_in", 7200)
|
||||||
self._token_expires_at = time.time() + expires_in
|
self._token_expires_at = time.time() + expires_in
|
||||||
debug("Access Token 刷新成功")
|
debug("Access Token 刷新成功")
|
||||||
return self._access_token
|
return self._access_token
|
||||||
else:
|
else:
|
||||||
error(f"获取 Token 失败: {data}")
|
error(f"获取 Token 失败: {data}")
|
||||||
raise Exception(f"WeChat API Error: {data}")
|
raise Exception(f"WeChat API Error: {data}")
|
||||||
|
|
||||||
# ================= 2. 上传媒体文件 =================
|
# ================= 2. 上传媒体文件 =================
|
||||||
async def upload_media(
|
async def upload_media(
|
||||||
self,
|
self,
|
||||||
file_path: Union[str, Path],
|
file_path: Union[str, Path],
|
||||||
media_type: str,
|
media_type: str,
|
||||||
is_permanent: bool = False,
|
is_permanent: bool = False,
|
||||||
title: Optional[str] = None,
|
title: Optional[str] = None,
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
上传媒体文件到微信服务器
|
上传媒体文件到微信服务器
|
||||||
|
|
||||||
:param file_path: 本地文件路径
|
:param file_path: 本地文件路径
|
||||||
:param media_type: 'image', 'voice', 'video', 'thumb'
|
:param media_type: 'image', 'voice', 'video', 'thumb'
|
||||||
:param is_permanent: False=临时素材(3天), True=永久素材
|
:param is_permanent: False=临时素材(3天), True=永久素材
|
||||||
:param title/description: 仅当上传永久视频时需要
|
:param title/description: 仅当上传永久视频时需要
|
||||||
"""
|
"""
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
raise FileNotFoundError(f"文件不存在: {file_path}")
|
raise FileNotFoundError(f"文件不存在: {file_path}")
|
||||||
|
|
||||||
token = await self.get_access_token()
|
token = await self.get_access_token()
|
||||||
|
|
||||||
# 区分临时和永久素材接口
|
# 区分临时和永久素材接口
|
||||||
if is_permanent:
|
if is_permanent:
|
||||||
url = f"https://api.weixin.qq.com/cgi-bin/material/add_material?access_token={token}&type={media_type}"
|
url = f"https://api.weixin.qq.com/cgi-bin/material/add_material?access_token={token}&type={media_type}"
|
||||||
else:
|
else:
|
||||||
url = f"https://api.weixin.qq.com/cgi-bin/media/upload?access_token={token}&type={media_type}"
|
url = f"https://api.weixin.qq.com/cgi-bin/media/upload?access_token={token}&type={media_type}"
|
||||||
|
|
||||||
form = aiohttp.FormData()
|
form = aiohttp.FormData()
|
||||||
f = open(file_path, 'rb') # 不用 with
|
f = open(file_path, 'rb') # 不用 with
|
||||||
@ -126,71 +126,71 @@ class WeChatMediaManager:
|
|||||||
finally:
|
finally:
|
||||||
f.close() # 手动关闭
|
f.close() # 手动关闭
|
||||||
|
|
||||||
# ================= 3. 下载媒体文件 =================
|
# ================= 3. 下载媒体文件 =================
|
||||||
async def download_media(
|
async def download_media(
|
||||||
self,
|
self,
|
||||||
media_id: str,
|
media_id: str,
|
||||||
save_path: Union[str, Path],
|
save_path: Union[str, Path],
|
||||||
is_permanent: bool = False
|
is_permanent: bool = False
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
根据 media_id 下载媒体文件到本地
|
根据 media_id 下载媒体文件到本地
|
||||||
|
|
||||||
:param media_id: 微信媒体 ID
|
:param media_id: 微信媒体 ID
|
||||||
:param save_path: 保存到的本地路径
|
:param save_path: 保存到的本地路径
|
||||||
:param is_permanent: False=临时素材下载, True=永久素材下载
|
:param is_permanent: False=临时素材下载, True=永久素材下载
|
||||||
:return: 保存的文件路径
|
:return: 保存的文件路径
|
||||||
"""
|
"""
|
||||||
token = await self.get_access_token()
|
token = await self.get_access_token()
|
||||||
|
|
||||||
# 区分临时和永久素材下载接口
|
# 区分临时和永久素材下载接口
|
||||||
if is_permanent:
|
if is_permanent:
|
||||||
url = f"https://api.weixin.qq.com/cgi-bin/material/get_material?access_token={token}"
|
url = f"https://api.weixin.qq.com/cgi-bin/material/get_material?access_token={token}"
|
||||||
# 永久素材下载需要 POST JSON body
|
# 永久素材下载需要 POST JSON body
|
||||||
post_data = {"media_id": media_id}
|
post_data = {"media_id": media_id}
|
||||||
method = "POST"
|
method = "POST"
|
||||||
else:
|
else:
|
||||||
url = f"https://api.weixin.qq.com/cgi-bin/media/get?access_token={token}&media_id={media_id}"
|
url = f"https://api.weixin.qq.com/cgi-bin/media/get?access_token={token}&media_id={media_id}"
|
||||||
post_data = None
|
post_data = None
|
||||||
method = "GET"
|
method = "GET"
|
||||||
|
|
||||||
timeout = aiohttp.ClientTimeout(total=120)
|
timeout = aiohttp.ClientTimeout(total=120)
|
||||||
|
|
||||||
# 确保目录存在
|
# 确保目录存在
|
||||||
os.makedirs(os.path.dirname(save_path) or '.', exist_ok=True)
|
os.makedirs(os.path.dirname(save_path) or '.', exist_ok=True)
|
||||||
|
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
async with session.get(url) as resp:
|
async with session.get(url) as resp:
|
||||||
return await self._handle_download_response(resp, save_path, media_id)
|
return await self._handle_download_response(resp, save_path, media_id)
|
||||||
else:
|
else:
|
||||||
async with session.post(url, json=post_data) as resp:
|
async with session.post(url, json=post_data) as resp:
|
||||||
return await self._handle_download_response(resp, save_path, media_id)
|
return await self._handle_download_response(resp, save_path, media_id)
|
||||||
|
|
||||||
async def _handle_download_response(self, resp: aiohttp.ClientResponse, save_path: str, media_id: str) -> str:
|
async def _handle_download_response(self, resp: aiohttp.ClientResponse, save_path: str, media_id: str) -> str:
|
||||||
"""处理下载响应流"""
|
"""处理下载响应流"""
|
||||||
content_type = resp.headers.get('Content-Type', '')
|
content_type = resp.headers.get('Content-Type', '')
|
||||||
|
|
||||||
# 微信出错时通常返回 JSON
|
# 微信出错时通常返回 JSON
|
||||||
if 'application/json' in content_type:
|
if 'application/json' in content_type:
|
||||||
error_data = await resp.json()
|
error_data = await resp.json()
|
||||||
error(f"下载失败 (JSON 错误): {error_data}")
|
error(f"下载失败 (JSON 错误): {error_data}")
|
||||||
raise Exception(f"WeChat Download Error: {error_data}")
|
raise Exception(f"WeChat Download Error: {error_data}")
|
||||||
|
|
||||||
# 流式写入文件,避免大文件撑爆内存
|
# 流式写入文件,避免大文件撑爆内存
|
||||||
with open(save_path, 'wb') as f:
|
with open(save_path, 'wb') as f:
|
||||||
async for chunk in resp.content.iter_chunked(8192): # 每次 8KB
|
async for chunk in resp.content.iter_chunked(8192): # 每次 8KB
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
|
|
||||||
debug(f"文件下载成功: {save_path}")
|
debug(f"文件下载成功: {save_path}")
|
||||||
return save_path
|
return save_path
|
||||||
|
|
||||||
# ================= 4. 获取临时素材 URL (仅临时素材有效) =================
|
# ================= 4. 获取临时素材 URL (仅临时素材有效) =================
|
||||||
async def get_media_url(self, media_id: str) -> str:
|
async def get_media_url(self, media_id: str) -> str:
|
||||||
"""
|
"""
|
||||||
获取临时素材的下载 URL (有效期3天)
|
获取临时素材的下载 URL (有效期3天)
|
||||||
注意:永久素材没有直接的 URL,必须下载
|
注意:永久素材没有直接的 URL,必须下载
|
||||||
"""
|
"""
|
||||||
token = await self.get_access_token()
|
token = await self.get_access_token()
|
||||||
return f"https://api.weixin.qq.com/cgi-bin/media/get?access_token={token}&media_id={media_id}"
|
return f"https://api.weixin.qq.com/cgi-bin/media/get?access_token={token}&media_id={media_id}"
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user