153 lines
5.5 KiB
Python
153 lines
5.5 KiB
Python
"""模块:公众号模板消息广播
|
||
|
||
依赖 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)
|