Files

153 lines
5.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""模块:公众号模板消息广播
依赖 gvsds.wechat.AccessToken 自动获取公众号 access_token。
token 失效时(errcode 40001/42001)自动清缓存以便下次刷新。
配置(app.ini [wechat] 段):
TEMPLATE_ID 默认模板 ID
BATCH_SIZE 分批发送大小,默认 100
常用 API:
from gvsds.wechat import Broadcast
Broadcast.Send(openid, template_id, data, url='')
Broadcast.SendBatch(openids, template_id, data, url='')
Broadcast.BroadcastAll(openids, template_id, data)
data 格式示例:
{'first': {'value': 'xxx'}, 'keyword1': {'value': 'yyy'}}
返回:{'success': int, 'fail': int, 'errors': [...]}
"""
import logging
import time
from django.core.cache import cache
from gvsds.app_config import get as _app_get, getint as _app_getint
from .token import AccessToken
logger = logging.getLogger(__name__)
_TEMPLATE_SEND_URL = 'https://api.weixin.qq.com/cgi-bin/message/template/send'
_BATCH_INTERVAL = 0.1 # 批次间隔秒数,防限流
_INVALID_TOKEN_CODES = (40001, 42001) # token 失效码
class Broadcast:
"""公众号模板消息广播器。"""
@staticmethod
def _DefaultTemplateId():
return _app_get('wechat', 'TEMPLATE_ID', fallback=None)
@staticmethod
def _BatchSize():
size = _app_getint('wechat', 'BATCH_SIZE', fallback=100)
return size if size > 0 else 100
@classmethod
def Send(cls, openid, template_id=None, data=None, url=''):
"""向单个 openid 发送模板消息。"""
if not openid:
return {'success': 0, 'fail': 1, 'errors': [{'openid': openid, 'error': 'openid 为空'}]}
template_id = template_id or cls._DefaultTemplateId()
if not template_id:
raise RuntimeError('[Broadcast] 未指定 template_id 且未配置默认模板')
try:
token = AccessToken.Get('official')
except Exception as e:
logger.error('[Broadcast] 获取 access_token 失败: %s', e, exc_info=True)
return {'success': 0, 'fail': 1, 'errors': [{'openid': openid, 'error': str(e)}]}
payload = {
'touser': openid,
'template_id': template_id,
'url': url or '',
'data': data or {},
}
ok, err = cls._Post(token, payload)
if ok:
return {'success': 1, 'fail': 0, 'errors': []}
return {'success': 0, 'fail': 1, 'errors': [{'openid': openid, 'error': err}]}
@classmethod
def SendBatch(cls, openids, template_id=None, data=None, url=''):
"""批量发送(同一模板与数据),分批防限流。"""
openids = list(openids or [])
template_id = template_id or cls._DefaultTemplateId()
if not template_id:
raise RuntimeError('[Broadcast] 未指定 template_id 且未配置默认模板')
success = 0
fail = 0
errors = []
if not openids:
return {'success': 0, 'fail': 0, 'errors': []}
batch_size = cls._BatchSize()
try:
token = AccessToken.Get('official')
except Exception as e:
logger.error('[Broadcast] 获取 access_token 失败: %s', e, exc_info=True)
return {
'success': 0,
'fail': len(openids),
'errors': [{'openid': oid, 'error': str(e)} for oid in openids],
}
for idx, openid in enumerate(openids):
if not openid:
fail += 1
errors.append({'openid': openid, 'error': 'openid 为空'})
continue
payload = {
'touser': openid,
'template_id': template_id,
'url': url or '',
'data': data or {},
}
ok, err = cls._Post(token, payload)
if ok:
success += 1
else:
fail += 1
errors.append({'openid': openid, 'error': err})
if (idx + 1) % batch_size == 0 and idx + 1 < len(openids):
time.sleep(_BATCH_INTERVAL)
return {'success': success, 'fail': fail, 'errors': errors}
@classmethod
def BroadcastAll(cls, openids, template_id=None, data=None):
"""按订阅用户广播(SendBatch 的语义化别名)。"""
return cls.SendBatch(openids, template_id=template_id, data=data, url='')
@classmethod
def _Post(cls, token, payload):
"""实际调用微信发送接口。返回 (ok, error_msg)。"""
try:
import requests
resp = requests.post(
_TEMPLATE_SEND_URL,
params={'access_token': token},
json=payload,
timeout=10,
)
result = resp.json()
except Exception as e:
logger.error('[Broadcast] 请求微信失败: %s', e, exc_info=True)
return False, f'请求异常: {e}'
errcode = result.get('errcode', 0)
if errcode == 0:
return True, ''
errmsg = result.get('errmsg', 'unknown')
logger.warning('[Broadcast] 发送失败 errcode=%s errmsg=%s', errcode, errmsg)
if errcode in _INVALID_TOKEN_CODES:
cls._InvalidateToken()
return False, f'{errcode}: {errmsg}'
@staticmethod
def _InvalidateToken():
"""token 失效时清缓存以便下次刷新。"""
try:
appid = AccessToken._GetConfig('official')[0]
cache.delete(AccessToken._CacheKey('official', appid))
except Exception:
logger.warning('[Broadcast] 清除 token 缓存失败', exc_info=True)