优化服务号订单广播(Celery并发)与群聊状态实时推送
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,31 +1,29 @@
|
||||
# utils/weixin_broadcast.py - 强调试版本
|
||||
import time
|
||||
# utils/weixin_broadcast.py — 服务号模板消息并发广播
|
||||
import logging
|
||||
import json # 新增,用于格式化打印
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import requests
|
||||
from django.core.cache import cache
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from yonghu.models import OfficialAccountUser
|
||||
|
||||
logger = logging.getLogger('weixin_broadcast')
|
||||
|
||||
_RATE_LIMIT_CODES = {45009, 45011, 45047}
|
||||
|
||||
|
||||
class WeixinBroadcastSender:
|
||||
"""
|
||||
微信模板消息广播发送器 - 调试版
|
||||
"""
|
||||
"""微信服务号模板消息发送器(连接复用 + 线程池并发)"""
|
||||
|
||||
def __init__(self):
|
||||
self.appid = getattr(settings, 'WEIXIN_OFFICIAL_APPID', '')
|
||||
self.secret = getattr(settings, 'WEIXIN_OFFICIAL_SECRET', '')
|
||||
self.template_id = getattr(settings, 'WEIXIN_TEMPLATE_ID', '')
|
||||
|
||||
# 发送控制参数
|
||||
self.batch_size = 100
|
||||
self.delay_between_batches = 1.5
|
||||
self._session = requests.Session()
|
||||
|
||||
def get_access_token(self):
|
||||
"""获取服务号access_token"""
|
||||
cache_key = f'weixin_official_token_{self.appid}'
|
||||
token = cache.get(cache_key)
|
||||
if token:
|
||||
@@ -34,168 +32,114 @@ class WeixinBroadcastSender:
|
||||
url = 'https://api.weixin.qq.com/cgi-bin/token'
|
||||
params = {'grant_type': 'client_credential', 'appid': self.appid, 'secret': self.secret}
|
||||
try:
|
||||
response = requests.get(url, params=params, timeout=10)
|
||||
result = response.json()
|
||||
resp = self._session.get(url, params=params, timeout=10)
|
||||
result = resp.json()
|
||||
if 'access_token' in result:
|
||||
token = result['access_token']
|
||||
expires_in = result.get('expires_in', 7200) - 300
|
||||
cache.set(cache_key, token, expires_in)
|
||||
logger.info("✅ 获取access_token成功")
|
||||
return token
|
||||
else:
|
||||
logger.error(f"❌ 获取access_token失败: {result}")
|
||||
return None
|
||||
logger.error(f'获取 access_token 失败: {result}')
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 获取access_token异常: {str(e)}")
|
||||
return None
|
||||
logger.error(f'获取 access_token 异常: {e}')
|
||||
return None
|
||||
|
||||
def build_order_message_data(self, order_info):
|
||||
"""
|
||||
构建订单消息数据 - 【调试关键点1】
|
||||
打印传入的 order_info 和构建出的最终数据
|
||||
"""
|
||||
# 🟡 调试点1:打印传入的参数
|
||||
logger.info(
|
||||
f"🟡【调试-输入】build_order_message_data 收到的 order_info: {json.dumps(order_info, ensure_ascii=False, indent=2)}")
|
||||
|
||||
# 这是你现在的构建逻辑,我们原样保留但加上调试
|
||||
# 注意:这里保持你目前的字段名(short_thing5, short_thing4, thing15, amount8)
|
||||
data = {
|
||||
"short_thing5": {
|
||||
"value": order_info.get('order_type', '平台发单'),
|
||||
"color": "#173177"
|
||||
},
|
||||
"short_thing4": {
|
||||
"value": order_info.get('game_type', '游戏订单'),
|
||||
"color": "#173177"
|
||||
},
|
||||
"thing15": {
|
||||
"value": str(order_info.get('order_desc', '新订单'))[:20],
|
||||
"color": "#173177"
|
||||
},
|
||||
"amount8": {
|
||||
"value": f"{order_info.get('amount', '0')}元",
|
||||
"color": "#173177"
|
||||
}
|
||||
order_type = order_info.get('order_type') or (
|
||||
'商家派单' if order_info.get('fadan_pingtai') == 2 else '平台发单'
|
||||
)
|
||||
return {
|
||||
'short_thing5': {'value': str(order_type)[:20], 'color': '#173177'},
|
||||
'short_thing4': {'value': str(order_info.get('game_type', '游戏订单'))[:20], 'color': '#173177'},
|
||||
'thing15': {'value': str(order_info.get('order_desc', '新订单'))[:20], 'color': '#173177'},
|
||||
'amount8': {'value': f"{order_info.get('amount', '0')}元", 'color': '#173177'},
|
||||
}
|
||||
|
||||
# 🟡 调试点2:打印构建出的最终数据结构
|
||||
logger.info(f"🟡【调试-输出】构建完成的模板数据 data 字段:")
|
||||
for key, content in data.items():
|
||||
logger.info(f" 字段名: '{key}' -> 值: '{content.get('value', '')}'")
|
||||
logger.info(f"🟡【调试-输出】完整 data 结构: {json.dumps(data, ensure_ascii=False)}")
|
||||
def send_to_user(self, official_openid, template_data, order_id=None, access_token=None):
|
||||
token = access_token or self.get_access_token()
|
||||
if not token:
|
||||
return {'success': False, 'msg': 'access_token 失败', 'retry': True}
|
||||
|
||||
# 🔴 关键检查:确认 short_thing5 是否存在,值是否为空
|
||||
if 'short_thing5' not in data:
|
||||
logger.error("❌【致命错误】构建的数据中根本没有 'short_thing5' 这个字段!")
|
||||
else:
|
||||
st5_value = data['short_thing5'].get('value', '')
|
||||
if not st5_value:
|
||||
logger.error(f"❌【致命错误】'short_thing5' 字段的值为空!")
|
||||
else:
|
||||
logger.info(f"✅ 'short_thing5' 字段存在,值为: '{st5_value}'")
|
||||
request_data = {
|
||||
'touser': official_openid,
|
||||
'template_id': self.template_id,
|
||||
'data': template_data,
|
||||
}
|
||||
if order_id and getattr(settings, 'WEIXIN_APPID', None):
|
||||
request_data['miniprogram'] = {
|
||||
'appid': settings.WEIXIN_APPID,
|
||||
'pagepath': f'pages/jiedan/jiedan?id={order_id}',
|
||||
}
|
||||
|
||||
return data
|
||||
url = f'https://api.weixin.qq.com/cgi-bin/message/template/send?access_token={token}'
|
||||
try:
|
||||
resp = self._session.post(url, json=request_data, timeout=12)
|
||||
result = resp.json()
|
||||
if result.get('errcode') == 0:
|
||||
return {'success': True, 'msgid': result.get('msgid')}
|
||||
|
||||
def send_to_user(self, official_openid, template_data, order_id=None):
|
||||
"""发送模板消息给单个用户 - 【调试关键点2】"""
|
||||
# 🟡 调试点3:打印每次发送前的数据
|
||||
logger.info(f"🟡【调试-发送前】准备发送给 {official_openid[:10]}... 的数据:")
|
||||
logger.info(f" 模板ID: {self.template_id}")
|
||||
logger.info(
|
||||
f" pagepath: pages/dingdan-xiangqing/dingdan-xiangqing?id={order_id}" if order_id else " 无pagepath")
|
||||
for key, content in template_data.items():
|
||||
logger.info(f" 字段 '{key}': '{content.get('value', '')}'")
|
||||
errcode = result.get('errcode')
|
||||
if errcode in (43004, 43101):
|
||||
OfficialAccountUser.objects.filter(official_openid=official_openid).update(is_subscribed=False)
|
||||
retry = errcode in _RATE_LIMIT_CODES
|
||||
return {'success': False, 'msg': result.get('errmsg'), 'errcode': errcode, 'retry': retry}
|
||||
except Exception as e:
|
||||
return {'success': False, 'msg': str(e), 'retry': True}
|
||||
|
||||
def broadcast_parallel(self, order_info):
|
||||
order_id = order_info.get('dingdan_id')
|
||||
template_data = self.build_order_message_data(order_info)
|
||||
|
||||
openids = list(
|
||||
OfficialAccountUser.objects.filter(is_subscribed=True).values_list('official_openid', flat=True)
|
||||
)
|
||||
total = len(openids)
|
||||
if total == 0:
|
||||
return {'success': True, 'msg': '无订阅用户', 'total': 0, 'success_count': 0, 'fail_count': 0}
|
||||
|
||||
max_workers = min(
|
||||
int(getattr(settings, 'WEIXIN_BROADCAST_WORKERS', 30)),
|
||||
max(8, total),
|
||||
)
|
||||
logger.info(f'广播开始 订单={order_id} 人数={total} 并发={max_workers}')
|
||||
|
||||
access_token = self.get_access_token()
|
||||
if not access_token:
|
||||
return {'success': False, 'msg': '获取access_token失败', 'retry': True}
|
||||
return {'success': False, 'msg': 'access_token 失败', 'total': total}
|
||||
|
||||
request_data = {
|
||||
"touser": official_openid,
|
||||
"template_id": self.template_id,
|
||||
"data": template_data
|
||||
}
|
||||
success = 0
|
||||
fail = 0
|
||||
rate_limited = 0
|
||||
|
||||
if order_id and hasattr(settings, 'WEIXIN_APPID'):
|
||||
request_data["miniprogram"] = {
|
||||
"appid": settings.WEIXIN_APPID,
|
||||
"pagepath": f"pages/jiedan/jiedan?id={order_id}"
|
||||
}
|
||||
def _send_one(openid):
|
||||
return self.send_to_user(openid, template_data, order_id, access_token=access_token)
|
||||
|
||||
send_url = f'https://api.weixin.qq.com/cgi-bin/message/template/send?access_token={access_token}'
|
||||
try:
|
||||
response = requests.post(send_url, json=request_data, timeout=10)
|
||||
result = response.json()
|
||||
logger.info(f"🟡【调试-微信响应】{official_openid[:10]}... 的发送结果: {result}")
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
futures = {pool.submit(_send_one, oid): oid for oid in openids}
|
||||
for fut in as_completed(futures):
|
||||
oid = futures[fut]
|
||||
try:
|
||||
res = fut.result(timeout=30)
|
||||
if res.get('success'):
|
||||
success += 1
|
||||
else:
|
||||
fail += 1
|
||||
if res.get('retry'):
|
||||
rate_limited += 1
|
||||
except Exception as e:
|
||||
fail += 1
|
||||
logger.warning(f'发送异常 {oid[:8]}...: {e}')
|
||||
|
||||
if result.get('errcode') == 0:
|
||||
return {'success': True, 'msg': '发送成功', 'msgid': result.get('msgid')}
|
||||
else:
|
||||
# 如果是未关注或拒收,更新本地状态
|
||||
if result.get('errcode') in [43004, 43101]:
|
||||
try:
|
||||
OfficialAccountUser.objects.filter(official_openid=official_openid).update(is_subscribed=False)
|
||||
except:
|
||||
pass
|
||||
return {'success': False, 'msg': f"[{result.get('errcode')}]{result.get('errmsg')}", 'retry': False}
|
||||
if rate_limited > 5:
|
||||
time.sleep(1.5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 发送给用户异常: {official_openid[:10]}..., 错误: {str(e)}")
|
||||
return {'success': False, 'msg': f'请求异常: {str(e)}', 'retry': True}
|
||||
|
||||
def broadcast_order(self, order_info):
|
||||
"""广播订单消息给所有关注用户 - 【调试入口】"""
|
||||
logger.info(f"🚀【开始广播】订单: {order_info.get('dingdan_id')}")
|
||||
|
||||
# 1. 获取所有关注用户
|
||||
try:
|
||||
subscribed_users = OfficialAccountUser.objects.filter(is_subscribed=True).values_list('official_openid',
|
||||
flat=True)
|
||||
total_users = subscribed_users.count()
|
||||
logger.info(f"📊【广播统计】将从数据库查询到 {total_users} 个订阅用户")
|
||||
if total_users == 0:
|
||||
return {'success': True, 'msg': '无订阅用户', 'total': 0}
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 获取订阅用户列表失败: {str(e)}")
|
||||
return {'success': False, 'msg': f'获取用户失败: {str(e)}'}
|
||||
|
||||
# 2. 构建模板数据(这里会触发上面的调试打印)
|
||||
template_data = self.build_order_message_data(order_info)
|
||||
order_id = order_info.get('dingdan_id')
|
||||
|
||||
# 3. 分批发送
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
current_batch = 0
|
||||
openid_list = list(subscribed_users)
|
||||
|
||||
logger.info(f"🟡【调试-广播循环】开始遍历 {len(openid_list)} 个用户进行发送...")
|
||||
for i, openid in enumerate(openid_list):
|
||||
try:
|
||||
result = self.send_to_user(openid, template_data, order_id)
|
||||
if result['success']:
|
||||
success_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
if fail_count <= 5: # 只打印前几个失败原因
|
||||
logger.warning(f"⚠️ 发送失败 {openid[:10]}...: {result.get('msg')}")
|
||||
|
||||
# 分批控制
|
||||
current_batch += 1
|
||||
if current_batch >= self.batch_size:
|
||||
time.sleep(self.delay_between_batches)
|
||||
current_batch = 0
|
||||
|
||||
except Exception as e:
|
||||
fail_count += 1
|
||||
logger.error(f"❌ 处理用户 {openid[:10]}... 异常: {str(e)}")
|
||||
|
||||
logger.info(f"🏁【广播完成】订单 {order_id},总计: {total_users},成功: {success_count},失败: {fail_count}")
|
||||
logger.info(f'广播完成 订单={order_id} 成功={success} 失败={fail} 总计={total}')
|
||||
return {
|
||||
'success': True,
|
||||
'msg': f'广播完成: 成功{success_count}, 失败{fail_count}',
|
||||
'total': total_users,
|
||||
'success_count': success_count,
|
||||
'fail_count': fail_count
|
||||
}
|
||||
'total': total,
|
||||
'success_count': success,
|
||||
'fail_count': fail,
|
||||
}
|
||||
|
||||
def broadcast_order(self, order_info):
|
||||
return self.broadcast_parallel(order_info)
|
||||
|
||||
Reference in New Issue
Block a user