优化服务号订单广播(Celery并发)与群聊状态实时推送

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-06-24 00:43:30 +08:00
parent 56c66846cc
commit 49ba67ecaf
9 changed files with 458 additions and 331 deletions

View File

@@ -166,7 +166,7 @@ def _ensure_avatar_url(avatar_url):
def _build_pair_group_to_data(dashou_goeasy_id, dashou_name, dashou_avatar,
partner_goeasy_id, partner_name, partner_avatar,
order_id=None, order_desc=None, is_cross=0,
viewer_goeasy_id=None):
viewer_goeasy_id=None, order_zuangtai=None):
"""群会话 to.data同时写入打手/对方双方 ID、昵称、头像前端按当前身份取对方展示"""
dashou_avatar = _ensure_avatar_url(dashou_avatar)
partner_avatar = _ensure_avatar_url(partner_avatar)
@@ -190,6 +190,8 @@ def _build_pair_group_to_data(dashou_goeasy_id, dashou_name, dashou_avatar,
to_data['orderId'] = order_id
if order_desc:
to_data['orderDesc'] = order_desc[:300]
if order_zuangtai is not None:
to_data['orderZhuangtai'] = order_zuangtai
if viewer_goeasy_id == dashou_goeasy_id:
to_data['name'] = partner_name
@@ -220,6 +222,7 @@ def build_pair_to_data_for_order(order, viewer_goeasy_id=None):
partner_goeasy_id, partner_name, partner_avatar,
order_id=order.dingdan_id, order_desc=order_desc,
is_cross=order.is_cross or 0, viewer_goeasy_id=viewer_goeasy_id,
order_zuangtai=order.zhuangtai,
)
@@ -832,4 +835,107 @@ def _create_local_group_only(order, dashou_goeasy_id, dashou_name, dashou_avatar
"订单已接单。", None, order_id=order.dingdan_id,
is_cross=order.is_cross, to_data_extra=to_data,
)
return subscribe_ok or msg_ok
return subscribe_ok or msg_ok
STATUS_NOTIFY_TEXT = {
2: '订单已开始服务',
3: '订单已完成',
4: '订单退款申请中',
5: '订单已退款',
6: '退款未通过',
8: '打手已提交交付,订单进入结算中',
}
def schedule_order_status_chat_push(dingdan_id):
"""订单状态变更后,事务提交后异步推送群聊状态更新"""
if not dingdan_id:
return
def _run():
try:
from dingdan.tasks import push_order_status_chat_task
push_order_status_chat_task.apply_async(args=[dingdan_id], countdown=1)
except Exception as e:
logger.error(f"调度订单状态聊天推送失败 {dingdan_id}: {e}", exc_info=True)
try:
from django.db import transaction
transaction.on_commit(_run)
except Exception:
_run()
def push_order_status_chat_update(dingdan_id):
"""向订单群推送状态提示 + 最新订单卡片(含实时 zhuangtai"""
try:
order = Dingdan.objects.select_related('pingtai_kuozhan', 'shangjia_kuozhan').get(dingdan_id=dingdan_id)
except Dingdan.DoesNotExist:
logger.warning(f"push_order_status_chat_update: 订单 {dingdan_id} 不存在")
return False
dashou_uid = order.jiedan_dashou_id
if not dashou_uid:
return False
appkey = _get_self_goeasy_appkey()
secret = _get_self_goeasy_secret()
if not appkey:
return False
dashou_goeasy_id = f'Ds{dashou_uid}'
dashou_name, dashou_avatar = _resolve_goeasy_user_display(dashou_goeasy_id)
partner_goeasy_id, partner_name, partner_avatar = _get_local_partner_info(order)
if not partner_goeasy_id:
return False
group_id = resolve_group_id_for_order(order, dashou_uid)
order_desc = (order.jieshao or '').strip()
if order.beizhu:
order_desc = f"{order_desc} | 备注:{order.beizhu}" if order_desc else f"备注:{order.beizhu}"
pair_to_data = _build_pair_group_to_data(
dashou_goeasy_id, dashou_name, dashou_avatar,
partner_goeasy_id, partner_name, partner_avatar,
order_id=order.dingdan_id, order_desc=order_desc,
is_cross=order.is_cross or 0, order_zuangtai=order.zhuangtai,
)
user_ids = list({dashou_goeasy_id, partner_goeasy_id})
_retry_call(_subscribe_users_to_group, 3, 0.4, user_ids, [group_id], appkey, secret)
status_text = STATUS_NOTIFY_TEXT.get(order.zhuangtai, '订单状态已更新')
notify_text = f"【订单状态】{status_text}"
_retry_call(
_send_group_message, 3, 0.4,
appkey, secret, group_id, dashou_goeasy_id, dashou_name, dashou_avatar,
notify_text, None, order_id=order.dingdan_id,
is_cross=order.is_cross or 0, to_data_extra=pair_to_data,
)
card_ok = _retry_call(
_push_order_card_message, 3, 0.4,
appkey, secret, order, group_id, dashou_goeasy_id, dashou_name, dashou_avatar,
pair_to_data,
)
partner_order_id = order.partner_order_id
partner_club_id = order.partner_club_id
if order.is_cross == 1 and partner_order_id and partner_club_id:
try:
partner_cfg = ClubConfig.objects.get(club_id=partner_club_id, is_self=0)
if partner_cfg and partner_cfg.chat_api_key:
partner_appkey = partner_cfg.chat_api_key
partner_secret = partner_cfg.chat_api_id or ''
partner_group_id = f"group_{partner_order_id}"
agent_id = f"{order.dingdan_id}_{dashou_goeasy_id}"
agent_name = f"[状态]{dashou_name}"
_retry_call(
_send_group_message, 2, 0.4,
partner_appkey, partner_secret, partner_group_id, agent_id, agent_name, '',
notify_text, None, order_id=partner_order_id, is_cross=1,
)
except Exception as e:
logger.warning(f"跨平台状态推送失败: {e}")
return card_ok

72
utils/order_broadcast.py Normal file
View File

@@ -0,0 +1,72 @@
"""
订单服务号模板消息广播 — 统一入口Celery 异步,接口立即返回)
"""
import logging
from django.conf import settings
logger = logging.getLogger('weixin_broadcast')
def _game_type_name(leixing_id):
try:
from shangpin.models import ShangpinLeixing
gt = ShangpinLeixing.objects.filter(id=leixing_id).first()
return gt.jieshao if gt else '游戏订单'
except Exception:
return '游戏订单'
def build_order_broadcast_payload(dingdan, order_type='普通订单', game_type=None):
return {
'dingdan_id': dingdan.dingdan_id,
'game_type': game_type or _game_type_name(dingdan.leixing_id),
'amount': str(dingdan.jine),
'order_desc': (dingdan.jieshao or '')[:50],
'order_type': order_type,
}
def submit_dingdan_guangbo(order_info):
"""提交 Celery 广播任务broadcast 优先队列)"""
if not getattr(settings, 'WEIXIN_BROADCAST_ENABLED', True):
logger.info('服务号广播已关闭,跳过')
return False
if not order_info or not order_info.get('dingdan_id'):
return False
try:
from dingdan.tongzhi_tasks import dingdan_guangbo
dingdan_guangbo.apply_async(
args=[order_info],
queue='broadcast',
priority=9,
countdown=0,
)
logger.info(f'已提交服务号广播任务: {order_info.get("dingdan_id")}')
return True
except Exception as e:
logger.error(f'Celery 广播提交失败,降级线程发送: {e}', exc_info=True)
_fallback_thread_broadcast(order_info)
return False
def schedule_dingdan_guangbo_from_order(dingdan, order_type='普通订单', game_type=None):
"""根据订单对象构建 payload 并提交广播"""
order_info = build_order_broadcast_payload(dingdan, order_type=order_type, game_type=game_type)
return submit_dingdan_guangbo(order_info)
def _fallback_thread_broadcast(order_info):
"""Celery 不可用时,后台线程并发发送(不阻塞下单接口)"""
import threading
from utils.weixin_broadcast import WeixinBroadcastSender
def _run():
try:
sender = WeixinBroadcastSender()
sender.broadcast_parallel(order_info)
except Exception as ex:
logger.error(f'线程降级广播失败: {ex}', exc_info=True)
t = threading.Thread(target=_run, daemon=True)
t.start()

View File

@@ -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)