优化服务号订单广播(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

@@ -272,6 +272,8 @@ WEIXIN_TEMPLATE_ID = 'Ngs1sT9TPyFaN4cA108YyMV_0nnutTynPQFXoOdNZZE' # 模板ID
# 🆕【新增】模板消息配置 # 🆕【新增】模板消息配置
WEIXIN_TEMPLATE_MAX_PER_MINUTE = 950 # 每分钟最大发送量留50缓冲 WEIXIN_TEMPLATE_MAX_PER_MINUTE = 950 # 每分钟最大发送量留50缓冲
WEIXIN_TEMPLATE_BATCH_SIZE = 100 # 每批发送数量 WEIXIN_TEMPLATE_BATCH_SIZE = 100 # 每批发送数量
WEIXIN_BROADCAST_ENABLED = True # 服务号订单广播总开关
WEIXIN_BROADCAST_WORKERS = 30 # Celery 任务内并发推送线程数(勿过大,避免触发微信频控)
WEIXIN_MCHID = '1746784529' # 商户号,支付功能需要紧急 WEIXIN_MCHID = '1746784529' # 商户号,支付功能需要紧急
@@ -453,6 +455,15 @@ CELERY_TASK_QUEUES = {
'exchange': 'periodic_tasks', 'exchange': 'periodic_tasks',
'routing_key': 'periodic_tasks', 'routing_key': 'periodic_tasks',
}, },
'broadcast': {
'exchange': 'broadcast',
'routing_key': 'broadcast',
},
}
CELERY_TASK_ROUTES = {
'dingdan.tongzhi_tasks.dingdan_guangbo': {'queue': 'broadcast'},
'dingdan.tasks.push_order_status_chat_task': {'queue': 'default'},
} }
# ==================== 定时任务时间配置 ==================== # ==================== 定时任务时间配置 ====================

View File

@@ -70,6 +70,12 @@ def process_expired_order(self, dingdan_id):
order.zhuangtai = 3 order.zhuangtai = 3
order.save(update_fields=['zhuangtai']) order.save(update_fields=['zhuangtai'])
try:
from utils.chat_utils import schedule_order_status_chat_push
schedule_order_status_chat_push(dingdan_id)
except Exception:
pass
# 2. 更新打手信息(如果存在) # 2. 更新打手信息(如果存在)
updated_dashou = False updated_dashou = False
if dashou_id: if dashou_id:
@@ -177,6 +183,24 @@ def retry_establish_order_chat_task(self, dingdan_id):
return str(e) return str(e)
@shared_task(bind=True, max_retries=2, default_retry_delay=10)
def push_order_status_chat_task(self, dingdan_id):
"""订单状态变更后推送群聊状态卡片"""
try:
from utils.chat_utils import push_order_status_chat_update
ok = push_order_status_chat_update(dingdan_id)
if ok:
return f'status chat ok {dingdan_id}'
if self.request.retries < self.max_retries:
raise self.retry()
return f'status chat failed {dingdan_id}'
except Exception as e:
logger.error(f"订单状态聊天推送异常 {dingdan_id}: {e}", exc_info=True)
if self.request.retries < self.max_retries:
raise self.retry(exc=e)
return str(e)
@shared_task @shared_task
@rollback_on_failure("批量检查超时订单") @rollback_on_failure("批量检查超时订单")
def check_order_expire_task(): def check_order_expire_task():

View File

@@ -1,71 +1,39 @@
# dingdan/tongzhi_tasks.py # dingdan/tongzhi_tasks.py
import logging import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from django.conf import settings
from a_long_dianjing.celery_app import app from a_long_dianjing.celery_app import app
from yonghu.models import OfficialAccountUser
from utils.weixin_broadcast import WeixinBroadcastSender from utils.weixin_broadcast import WeixinBroadcastSender
logger = logging.getLogger('weixin_broadcast') logger = logging.getLogger('weixin_broadcast')
@app.task(bind=True, max_retries=3, default_retry_delay=60) @app.task(
bind=True,
name='dingdan.tongzhi_tasks.dingdan_guangbo',
max_retries=2,
default_retry_delay=30,
queue='broadcast',
soft_time_limit=240,
time_limit=300,
)
def dingdan_guangbo(self, order_info): def dingdan_guangbo(self, order_info):
""" """
异步并发广播订单模板消息给所有关注服务号的用户 Celery并发向所有关注服务号的用户推送订单模板消息
order_info 字典字段示例:
{
'dingdan_id': 'PT123456',
'game_type': '王者荣耀',
'amount': '88.00',
'order_desc': '星耀上王者'
}
""" """
sender = WeixinBroadcastSender() if not getattr(settings, 'WEIXIN_BROADCAST_ENABLED', True):
template_data = sender.build_order_message_data(order_info) return {'success': True, 'msg': '广播已关闭'}
order_id = order_info.get('dingdan_id') order_id = order_info.get('dingdan_id')
# 查询所有已关注用户
try: try:
openids = list( sender = WeixinBroadcastSender()
OfficialAccountUser.objects result = sender.broadcast_parallel(order_info)
.filter(is_subscribed=True) if not result.get('success') and self.request.retries < self.max_retries:
.values_list('official_openid', flat=True) raise self.retry(countdown=30)
) return result
except Exception as e: except Exception as e:
logger.error(f'查询订阅用户失败: {e}') logger.error(f'广播任务异常 订单={order_id}: {e}', exc_info=True)
raise self.retry(exc=e) if self.request.retries < self.max_retries:
raise self.retry(exc=e)
if not openids: return {'success': False, 'msg': str(e), 'order_id': order_id}
return {'success': True, 'msg': '无订阅用户'}
total = len(openids)
logger.info(f'📊 Celery 广播订单 {order_id} 开始,目标人数: {total}')
success = 0
fail = 0
# 20 个线程并发发送,微信接口耗时 200-500ms1000 人约 15 秒完成
with ThreadPoolExecutor(max_workers=20) as executor:
future_map = {
executor.submit(sender.send_to_user, openid, template_data, order_id): openid
for openid in openids
}
for future in as_completed(future_map):
openid = future_map[future]
try:
res = future.result(timeout=30)
if res.get('success'):
success += 1
else:
fail += 1
except Exception as e:
fail += 1
logger.error(f'发送异常 {openid[:10]}...: {e}')
logger.info(f'🏁 广播完成:成功 {success},失败 {fail},总计 {total}')
return {
'success': True,
'total': total,
'success_count': success,
'fail_count': fail
}

View File

@@ -666,34 +666,12 @@ class WechatPayNotifyView(APIView):
logger.error(f"更新收支记录失败: {e}") logger.error(f"更新收支记录失败: {e}")
def _broadcast_async(self, dingdan): def _broadcast_async(self, dingdan):
"""异步广播通知""" """服务号模板消息广播Celery 优先队列,接口不阻塞)"""
try: try:
def task(): from utils.order_broadcast import schedule_dingdan_guangbo_from_order
try: schedule_dingdan_guangbo_from_order(dingdan)
from . import WeixinBroadcastSender # 请根据实际路径修改
order_info = {
'dingdan_id': dingdan.dingdan_id,
'game_type': self._get_game_type_name(dingdan.leixing_id),
'amount': str(dingdan.jine),
'order_desc': (dingdan.jieshao or '')[:50],
}
logger.info(f"准备广播订单: {order_info}")
sender = WeixinBroadcastSender()
result = sender.broadcast_order(order_info)
if result.get('success'):
logger.info(f"订单广播完成: {result.get('msg')}")
else:
logger.warning(f"订单广播异常: {result.get('msg')}")
except Exception as e:
logger.error(f"广播任务异常: {e}")
logger.error(traceback.format_exc())
thread = threading.Thread(target=task)
thread.daemon = True
thread.start()
logger.info("广播线程已启动")
except Exception as e: except Exception as e:
logger.error(f"启动广播线程异常: {e}") logger.error(f'提交广播任务失败: {e}', exc_info=True)
def _get_game_type_name(self, leixing_id): def _get_game_type_name(self, leixing_id):
try: try:
@@ -1297,27 +1275,11 @@ class VirtualPayNotifyView(APIView):
logger.error(f"更新收支记录失败: {e}") logger.error(f"更新收支记录失败: {e}")
def _broadcast_async(self, dingdan): def _broadcast_async(self, dingdan):
"""异步广播通知(同旧)"""
try: try:
def task(): from utils.order_broadcast import schedule_dingdan_guangbo_from_order
try: schedule_dingdan_guangbo_from_order(dingdan)
from . import WeixinBroadcastSender
order_info = {
'dingdan_id': dingdan.dingdan_id,
'game_type': self._get_game_type_name(dingdan.leixing_id),
'amount': str(dingdan.jine),
'order_desc': (dingdan.jieshao or '')[:50],
}
sender = WeixinBroadcastSender()
sender.broadcast_order(order_info)
except Exception as e:
logger.error(f"广播任务异常: {e}")
thread = threading.Thread(target=task)
thread.daemon = True
thread.start()
except Exception as e: except Exception as e:
logger.error(f"启动广播线程异常: {e}") logger.error(f'提交广播任务失败: {e}', exc_info=True)
def _get_game_type_name(self, leixing_id): def _get_game_type_name(self, leixing_id):
try: try:
@@ -2292,6 +2254,9 @@ class JiedanView(APIView):
dingdan.clkf = current_user.phone if hasattr(current_user, 'phone') else yonghuid dingdan.clkf = current_user.phone if hasattr(current_user, 'phone') else yonghuid
dingdan.save() dingdan.save()
from utils.chat_utils import schedule_order_status_chat_push
schedule_order_status_chat_push(dingdan.dingdan_id)
# 事务提交后异步通知对方平台 # 事务提交后异步通知对方平台
transaction.on_commit( transaction.on_commit(
lambda: self._notify_partner_jiedan( lambda: self._notify_partner_jiedan(
@@ -2341,6 +2306,9 @@ class JiedanView(APIView):
dingdan.zhuangtai = 3 dingdan.zhuangtai = 3
dingdan.save() dingdan.save()
from utils.chat_utils import schedule_order_status_chat_push
schedule_order_status_chat_push(dingdan.dingdan_id)
# 9. 返回成功响应 # 9. 返回成功响应
return Response({ return Response({
'code': 0, 'code': 0,
@@ -2738,16 +2706,10 @@ class ShangjiaPaifaView(APIView):
except Exception as e: except Exception as e:
logger.error(f"跨平台同步失败: {e}", exc_info=True) logger.error(f"跨平台同步失败: {e}", exc_info=True)
# Celery 广播任务(异步 # Celery 广播任务(broadcast 优先队列
try: try:
order_info = { from utils.order_broadcast import schedule_dingdan_guangbo_from_order
'dingdan_id': dingdan.dingdan_id, schedule_dingdan_guangbo_from_order(dingdan, order_type='商家派单')
'game_type': self._get_game_type_name(dingdan.leixing_id),
'amount': str(dingdan.jine),
'order_desc': (dingdan.jieshao or '')[:50],
}
dingdan_guangbo.delay(order_info)
logger.info(f"已提交广播任务: {dingdan.dingdan_id}")
except Exception as e: except Exception as e:
logger.error(f"广播任务提交失败: {e}", exc_info=True) logger.error(f"广播任务提交失败: {e}", exc_info=True)
@@ -3045,6 +3007,8 @@ class ShangjiaDingdanXiangqingView(APIView):
'beizhu': order.beizhu or '', 'beizhu': order.beizhu or '',
'nicheng': order.nicheng or '', 'nicheng': order.nicheng or '',
'create_time': order.create_time.strftime('%Y-%m-%d %H:%M:%S') if order.create_time else '', 'create_time': order.create_time.strftime('%Y-%m-%d %H:%M:%S') if order.create_time else '',
'fadanpingtai': order.fadan_pingtai or 0,
'is_cross': order.is_cross or 0,
# 打手信息 # 打手信息
'dashou_yonghuid': dashou_yonghuid, 'dashou_yonghuid': dashou_yonghuid,
@@ -3313,6 +3277,9 @@ class ShangjiaJiesuanView(APIView):
action=2 # 2 = 结算 action=2 # 2 = 结算
) )
from utils.chat_utils import schedule_order_status_chat_push
schedule_order_status_chat_push(dingdan_id)
settle_ctx = (dingdan_id, jiedan_dashou_id) settle_ctx = (dingdan_id, jiedan_dashou_id)
success_response = Response({ success_response = Response({
'code': 0, 'code': 0,
@@ -3468,6 +3435,9 @@ class ShangjiaChexiaoView(APIView):
dingdan.save() dingdan.save()
from utils.chat_utils import schedule_order_status_chat_push
schedule_order_status_chat_push(dingdan_id)
return Response({ return Response({
'code': 0, 'code': 0,
'msg': '撤销成功', 'msg': '撤销成功',
@@ -3592,6 +3562,9 @@ class ShangjiaTuikuanShenqingView(APIView):
dingdan.tkly = tuikuan_liyou dingdan.tkly = tuikuan_liyou
dingdan.save() dingdan.save()
from utils.chat_utils import schedule_order_status_chat_push
schedule_order_status_chat_push(dingdan_id)
# ====== 原有功能:更新打手状态 ====== # ====== 原有功能:更新打手状态 ======
if dingdan.jiedan_dashou_id: if dingdan.jiedan_dashou_id:
try: try:
@@ -4417,7 +4390,7 @@ import logging
import requests import requests
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status, permissions from rest_framework import status, permissions, parsers
from .models import Dingdan, DingdanPingtai, DingdanShangjia from .models import Dingdan, DingdanPingtai, DingdanShangjia
from yonghu.models import UserMain, UserDashou, UserBoss, UserShangjia from yonghu.models import UserMain, UserDashou, UserBoss, UserShangjia
from peizhi.models import ClubConfig from peizhi.models import ClubConfig
@@ -4497,12 +4470,13 @@ def subscribe_users_to_group(user_ids, group_ids, appkey=None, secret=None):
def send_group_message( def send_group_message(
appkey, secret, group_id, sender_id, sender_name, sender_avatar, appkey, secret, group_id, sender_id, sender_name, sender_avatar,
message_text='', custom_payload=None, group_name='', order_id='', is_cross=0, message_text='', custom_payload=None, group_name='', order_id='', is_cross=0,
to_data_extra=None, to_data_extra=None, message_type='text', image_url=None,
): ):
""" """
通过 GoEasy REST API 发送群消息。 通过 GoEasy REST API 发送群消息。
message_text: 普通文本内容 message_text: 普通文本内容
custom_payload: 自定义消息体(如订单卡片),此时 type 设为 'order' custom_payload: 自定义消息体(如订单卡片),此时 type 设为 'order'
image_url: 图片完整 URLmessage_type='image' 时使用
""" """
url = 'https://rest-hangzhou.goeasy.io/v2/im/message' url = 'https://rest-hangzhou.goeasy.io/v2/im/message'
sender_avatar = sender_avatar or '' sender_avatar = sender_avatar or ''
@@ -4519,8 +4493,16 @@ def send_group_message(
"orderId": order_id, "orderId": order_id,
"isCross": is_cross "isCross": is_cross
} }
msg_type = 'order' if custom_payload else 'text'
payload = custom_payload if custom_payload else message_text if message_type == 'image' and image_url:
msg_type = 'image'
payload = {"url": image_url}
elif custom_payload:
msg_type = 'order'
payload = custom_payload
else:
msg_type = 'text'
payload = message_text
body = { body = {
"appkey": appkey, "appkey": appkey,
@@ -4653,6 +4635,7 @@ class DaiLiQunLiaoXiaoXiView(APIView):
跨平台判断条件:订单同时存在 partner_order_id 和 partner_club_id 即视为跨平台 跨平台判断条件:订单同时存在 partner_order_id 和 partner_club_id 即视为跨平台
""" """
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
parser_classes = [parsers.MultiPartParser, parsers.FormParser, parsers.JSONParser]
def post(self, request): def post(self, request):
# ===== 1. 参数解析 ===== # ===== 1. 参数解析 =====
@@ -4663,6 +4646,7 @@ class DaiLiQunLiaoXiaoXiView(APIView):
message_type = request.data.get('messageType', 'text') message_type = request.data.get('messageType', 'text')
message_text = request.data.get('text', '') message_text = request.data.get('text', '')
order_payload = request.data.get('orderPayload', None) order_payload = request.data.get('orderPayload', None)
image_file = request.FILES.get('file')
logger.info(f"[群聊代理] 收到请求: orderId={order_id}, identityType={identity_type}, " logger.info(f"[群聊代理] 收到请求: orderId={order_id}, identityType={identity_type}, "
f"messageType={message_type}, text={message_text[:20]}...") f"messageType={message_type}, text={message_text[:20]}...")
@@ -4716,24 +4700,56 @@ class DaiLiQunLiaoXiaoXiView(APIView):
custom_payload = order_payload if message_type == 'order' else None custom_payload = order_payload if message_type == 'order' else None
text_content = message_text text_content = message_text
image_url = None
if message_type == 'image':
if not image_file:
return Response({'code': 400, 'msg': '缺少图片文件'})
try:
from utils.oss_utils import upload_to_oss, validate_image
import uuid as _uuid
validate_image(image_file)
oss_path = f"chat/images/{order_id}/{_uuid.uuid4().hex}.jpg"
image_url = upload_to_oss(image_file, oss_path)
if not image_url:
return Response({'code': 500, 'msg': '图片上传失败'})
except Exception as e:
logger.error(f"[群聊代理] 图片上传失败: {e}", exc_info=True)
return Response({'code': 500, 'msg': '图片上传失败'})
pair_to_data = build_pair_to_data_for_order(order, viewer_goeasy_id=sender_id) pair_to_data = build_pair_to_data_for_order(order, viewer_goeasy_id=sender_id)
# ---------- 先向我方群发送消息(永远执行) ---------- # ---------- 先向我方群发送消息(永远执行) ----------
local_ok = send_group_message( if message_type == 'image' and image_url:
appkey=self_appkey, local_ok = send_group_message(
secret=self_secret, appkey=self_appkey,
group_id=real_group_id, secret=self_secret,
sender_id=sender_id, group_id=real_group_id,
sender_name=sender_name, sender_id=sender_id,
sender_avatar=sender_avatar, sender_name=sender_name,
message_text=text_content, sender_avatar=sender_avatar,
custom_payload=custom_payload, group_name=group_name,
group_name=group_name, order_id=order.dingdan_id,
order_id=order.dingdan_id, is_cross=is_cross,
is_cross=is_cross, to_data_extra=pair_to_data,
to_data_extra=pair_to_data, message_type='image',
) image_url=image_url,
)
else:
local_ok = send_group_message(
appkey=self_appkey,
secret=self_secret,
group_id=real_group_id,
sender_id=sender_id,
sender_name=sender_name,
sender_avatar=sender_avatar,
message_text=text_content,
custom_payload=custom_payload,
group_name=group_name,
order_id=order.dingdan_id,
is_cross=is_cross,
to_data_extra=pair_to_data,
)
if not local_ok: if not local_ok:
return Response({'code': 500, 'msg': '消息发送失败'}) return Response({'code': 500, 'msg': '消息发送失败'})
@@ -5626,6 +5642,9 @@ class DashouTijiaoView(APIView):
dingdan.update_time = timezone.now() dingdan.update_time = timezone.now()
dingdan.save() dingdan.save()
from utils.chat_utils import schedule_order_status_chat_push
schedule_order_status_chat_push(dingdan_id)
# 7. 更新打手扩展表状态 # 7. 更新打手扩展表状态
dashou_profile.zhuangtai = 1 # 改为数字1根据你之前说的 dashou_profile.zhuangtai = 1 # 改为数字1根据你之前说的
dashou_profile.save() dashou_profile.save()
@@ -5720,7 +5739,8 @@ class DashouDingdanXiangqingView(APIView):
'dashou_liuyan': dingdan.dashou_liuyan or '', 'dashou_liuyan': dingdan.dashou_liuyan or '',
'fuwudashou_id': dingdan.jiedan_dashou_id or '', 'fuwudashou_id': dingdan.jiedan_dashou_id or '',
'fadanpingtai': dingdan.fadan_pingtai or '', 'fadanpingtai': dingdan.fadan_pingtai or '',
'tuikuan_liyou': dingdan.tkly or '' 'tuikuan_liyou': dingdan.tkly or '',
'is_cross': dingdan.is_cross or 0,
} }
# 6. 查询打手提交的图片 # 6. 查询打手提交的图片
@@ -6348,6 +6368,9 @@ class AdQiangZhiJieDan(APIView):
dingdan_obj.zhuangtai = 3 dingdan_obj.zhuangtai = 3
dingdan_obj.save() dingdan_obj.save()
from utils.chat_utils import schedule_order_status_chat_push
schedule_order_status_chat_push(dingdan_id)
# 10.2 处理打手结算 # 10.2 处理打手结算
if jiedan_dashou_id: if jiedan_dashou_id:
try: try:
@@ -9012,13 +9035,14 @@ class ZxsjghdsView(APIView):
# 7.4 异步通知打手(广播新订单) # 7.4 异步通知打手(广播新订单)
try: try:
order_info = { from utils.order_broadcast import submit_dingdan_guangbo
submit_dingdan_guangbo({
'dingdan_id': new_dingdan_id, 'dingdan_id': new_dingdan_id,
'game_type': self._get_game_type_name(leixing_id), 'game_type': self._get_game_type_name(leixing_id),
'amount': str(jine), 'amount': str(jine),
'order_desc': (jieshao or '')[:50], 'order_desc': (jieshao or '')[:50],
} 'order_type': '商家派单',
dingdan_guangbo.delay(order_info) })
except Exception as e: except Exception as e:
logger.error(f"广播通知失败: {e}") logger.error(f"广播通知失败: {e}")

View File

@@ -10840,13 +10840,14 @@ class ZxkfghdsView(APIView):
# ---- 6.5 异步通知(广播新订单、跨平台同步) ---- # ---- 6.5 异步通知(广播新订单、跨平台同步) ----
try: try:
order_info = { from utils.order_broadcast import submit_dingdan_guangbo
submit_dingdan_guangbo({
'dingdan_id': new_dingdan_id, 'dingdan_id': new_dingdan_id,
'game_type': self._get_game_type_name(new_order.leixing_id), 'game_type': self._get_game_type_name(new_order.leixing_id),
'amount': str(new_order.jine), 'amount': str(new_order.jine),
'order_desc': (new_order.jieshao or '')[:50], 'order_desc': (new_order.jieshao or '')[:50],
} 'order_type': '商家派单',
dingdan_guangbo.delay(order_info) })
except Exception as e: except Exception as e:
logger.error(f"广播通知失败: {e}") logger.error(f"广播通知失败: {e}")

View File

@@ -3272,48 +3272,25 @@ class KehuTianxieDingdanView(APIView):
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def send_order_notification_async(self, dingdan, youxi_nicheng, zhiding_dashou, zhuangtai): def send_order_notification_async(self, dingdan, youxi_nicheng, zhiding_dashou, zhuangtai):
"""异步发送订单通知(客户填写订单后)""" """客户填写订单后 — Celery 广播服务号模板消息"""
try: try:
# 避免阻塞主线程,使用线程异步执行 from utils.order_broadcast import submit_dingdan_guangbo
def notification_task(): try:
try: leixing_obj = ShangpinLeixing.objects.get(id=dingdan.leixing_id)
# 获取游戏类型名称 game_type = leixing_obj.jieshao
try: except Exception:
leixing_obj = ShangpinLeixing.objects.get(id=dingdan.leixing_id) game_type = '未知游戏'
game_type = leixing_obj.jieshao
except:
game_type = '未知游戏'
# 构建订单信息
order_info = {
'dingdan_id': dingdan.dingdan_id,
'game_type': game_type,
'amount': str(dingdan.jine),
'order_desc': dingdan.jieshao[:50] if dingdan.jieshao else '新订单',
'youxi_nicheng': youxi_nicheng,
'order_type': '指定订单' if zhuangtai == 7 else '普通订单',
'zhiding_dashou': zhiding_dashou or ''
}
# 调用微信广播发送器
sender = WeixinBroadcastSender()
result = sender.broadcast_order(order_info)
if result['success']:
logger.info(f"【客户填写订单】微信通知发送成功: {result['msg']}")
else:
logger.warning(f"【客户填写订单】微信通知发送失败: {result['msg']}")
except Exception as e:
logger.error(f"【客户填写订单】发送通知任务异常: {str(e)}")
# 启动异步线程
thread = threading.Thread(target=notification_task)
thread.daemon = True
thread.start()
order_info = {
'dingdan_id': dingdan.dingdan_id,
'game_type': game_type,
'amount': str(dingdan.jine),
'order_desc': dingdan.jieshao[:50] if dingdan.jieshao else '新订单',
'order_type': '指定订单' if zhuangtai == 7 else '普通订单',
}
submit_dingdan_guangbo(order_info)
except Exception as e: except Exception as e:
logger.error(f"【客户填写订单】启动通知线程异常: {str(e)}") logger.error(f'【客户填写订单】提交广播失败: {e}', exc_info=True)

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, def _build_pair_group_to_data(dashou_goeasy_id, dashou_name, dashou_avatar,
partner_goeasy_id, partner_name, partner_avatar, partner_goeasy_id, partner_name, partner_avatar,
order_id=None, order_desc=None, is_cross=0, order_id=None, order_desc=None, is_cross=0,
viewer_goeasy_id=None): viewer_goeasy_id=None, order_zuangtai=None):
"""群会话 to.data同时写入打手/对方双方 ID、昵称、头像前端按当前身份取对方展示""" """群会话 to.data同时写入打手/对方双方 ID、昵称、头像前端按当前身份取对方展示"""
dashou_avatar = _ensure_avatar_url(dashou_avatar) dashou_avatar = _ensure_avatar_url(dashou_avatar)
partner_avatar = _ensure_avatar_url(partner_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 to_data['orderId'] = order_id
if order_desc: if order_desc:
to_data['orderDesc'] = order_desc[:300] to_data['orderDesc'] = order_desc[:300]
if order_zuangtai is not None:
to_data['orderZhuangtai'] = order_zuangtai
if viewer_goeasy_id == dashou_goeasy_id: if viewer_goeasy_id == dashou_goeasy_id:
to_data['name'] = partner_name 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, partner_goeasy_id, partner_name, partner_avatar,
order_id=order.dingdan_id, order_desc=order_desc, order_id=order.dingdan_id, order_desc=order_desc,
is_cross=order.is_cross or 0, viewer_goeasy_id=viewer_goeasy_id, 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, "订单已接单。", None, order_id=order.dingdan_id,
is_cross=order.is_cross, to_data_extra=to_data, 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 - 强调试版本 # utils/weixin_broadcast.py — 服务号模板消息并发广播
import time
import logging import logging
import json # 新增,用于格式化打印 import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests import requests
from django.core.cache import cache
from django.conf import settings from django.conf import settings
from django.core.cache import cache
from yonghu.models import OfficialAccountUser from yonghu.models import OfficialAccountUser
logger = logging.getLogger('weixin_broadcast') logger = logging.getLogger('weixin_broadcast')
_RATE_LIMIT_CODES = {45009, 45011, 45047}
class WeixinBroadcastSender: class WeixinBroadcastSender:
""" """微信服务号模板消息发送器(连接复用 + 线程池并发)"""
微信模板消息广播发送器 - 调试版
"""
def __init__(self): def __init__(self):
self.appid = getattr(settings, 'WEIXIN_OFFICIAL_APPID', '') self.appid = getattr(settings, 'WEIXIN_OFFICIAL_APPID', '')
self.secret = getattr(settings, 'WEIXIN_OFFICIAL_SECRET', '') self.secret = getattr(settings, 'WEIXIN_OFFICIAL_SECRET', '')
self.template_id = getattr(settings, 'WEIXIN_TEMPLATE_ID', '') self.template_id = getattr(settings, 'WEIXIN_TEMPLATE_ID', '')
self._session = requests.Session()
# 发送控制参数
self.batch_size = 100
self.delay_between_batches = 1.5
def get_access_token(self): def get_access_token(self):
"""获取服务号access_token"""
cache_key = f'weixin_official_token_{self.appid}' cache_key = f'weixin_official_token_{self.appid}'
token = cache.get(cache_key) token = cache.get(cache_key)
if token: if token:
@@ -34,168 +32,114 @@ class WeixinBroadcastSender:
url = 'https://api.weixin.qq.com/cgi-bin/token' url = 'https://api.weixin.qq.com/cgi-bin/token'
params = {'grant_type': 'client_credential', 'appid': self.appid, 'secret': self.secret} params = {'grant_type': 'client_credential', 'appid': self.appid, 'secret': self.secret}
try: try:
response = requests.get(url, params=params, timeout=10) resp = self._session.get(url, params=params, timeout=10)
result = response.json() result = resp.json()
if 'access_token' in result: if 'access_token' in result:
token = result['access_token'] token = result['access_token']
expires_in = result.get('expires_in', 7200) - 300 expires_in = result.get('expires_in', 7200) - 300
cache.set(cache_key, token, expires_in) cache.set(cache_key, token, expires_in)
logger.info("✅ 获取access_token成功")
return token return token
else: logger.error(f'获取 access_token 失败: {result}')
logger.error(f"❌ 获取access_token失败: {result}")
return None
except Exception as e: except Exception as e:
logger.error(f"获取access_token异常: {str(e)}") logger.error(f'获取 access_token 异常: {e}')
return None return None
def build_order_message_data(self, order_info): def build_order_message_data(self, order_info):
""" order_type = order_info.get('order_type') or (
构建订单消息数据 - 【调试关键点1】 '商家派单' if order_info.get('fadan_pingtai') == 2 else '平台发单'
打印传入的 order_info 和构建出的最终数据 )
""" return {
# 🟡 调试点1打印传入的参数 'short_thing5': {'value': str(order_type)[:20], 'color': '#173177'},
logger.info( 'short_thing4': {'value': str(order_info.get('game_type', '游戏订单'))[:20], 'color': '#173177'},
f"🟡【调试-输入】build_order_message_data 收到的 order_info: {json.dumps(order_info, ensure_ascii=False, indent=2)}") 'thing15': {'value': str(order_info.get('order_desc', '新订单'))[:20], 'color': '#173177'},
'amount8': {'value': f"{order_info.get('amount', '0')}", 'color': '#173177'},
# 这是你现在的构建逻辑,我们原样保留但加上调试
# 注意这里保持你目前的字段名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"
}
} }
# 🟡 调试点2打印构建出的最终数据结构 def send_to_user(self, official_openid, template_data, order_id=None, access_token=None):
logger.info(f"🟡【调试-输出】构建完成的模板数据 data 字段:") token = access_token or self.get_access_token()
for key, content in data.items(): if not token:
logger.info(f" 字段名: '{key}' -> 值: '{content.get('value', '')}'") return {'success': False, 'msg': 'access_token 失败', 'retry': True}
logger.info(f"🟡【调试-输出】完整 data 结构: {json.dumps(data, ensure_ascii=False)}")
# 🔴 关键检查:确认 short_thing5 是否存在,值是否为空 request_data = {
if 'short_thing5' not in data: 'touser': official_openid,
logger.error("❌【致命错误】构建的数据中根本没有 'short_thing5' 这个字段!") 'template_id': self.template_id,
else: 'data': template_data,
st5_value = data['short_thing5'].get('value', '') }
if not st5_value: if order_id and getattr(settings, 'WEIXIN_APPID', None):
logger.error(f"❌【致命错误】'short_thing5' 字段的值为空!") request_data['miniprogram'] = {
else: 'appid': settings.WEIXIN_APPID,
logger.info(f"'short_thing5' 字段存在,值为: '{st5_value}'") '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): errcode = result.get('errcode')
"""发送模板消息给单个用户 - 【调试关键点2】""" if errcode in (43004, 43101):
# 🟡 调试点3打印每次发送前的数据 OfficialAccountUser.objects.filter(official_openid=official_openid).update(is_subscribed=False)
logger.info(f"🟡【调试-发送前】准备发送给 {official_openid[:10]}... 的数据:") retry = errcode in _RATE_LIMIT_CODES
logger.info(f" 模板ID: {self.template_id}") return {'success': False, 'msg': result.get('errmsg'), 'errcode': errcode, 'retry': retry}
logger.info( except Exception as e:
f" pagepath: pages/dingdan-xiangqing/dingdan-xiangqing?id={order_id}" if order_id else " 无pagepath") return {'success': False, 'msg': str(e), 'retry': True}
for key, content in template_data.items():
logger.info(f" 字段 '{key}': '{content.get('value', '')}'") 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() access_token = self.get_access_token()
if not access_token: if not access_token:
return {'success': False, 'msg': '获取access_token失败', 'retry': True} return {'success': False, 'msg': 'access_token 失败', 'total': total}
request_data = { success = 0
"touser": official_openid, fail = 0
"template_id": self.template_id, rate_limited = 0
"data": template_data
}
if order_id and hasattr(settings, 'WEIXIN_APPID'): def _send_one(openid):
request_data["miniprogram"] = { return self.send_to_user(openid, template_data, order_id, access_token=access_token)
"appid": settings.WEIXIN_APPID,
"pagepath": f"pages/jiedan/jiedan?id={order_id}"
}
send_url = f'https://api.weixin.qq.com/cgi-bin/message/template/send?access_token={access_token}' with ThreadPoolExecutor(max_workers=max_workers) as pool:
try: futures = {pool.submit(_send_one, oid): oid for oid in openids}
response = requests.post(send_url, json=request_data, timeout=10) for fut in as_completed(futures):
result = response.json() oid = futures[fut]
logger.info(f"🟡【调试-微信响应】{official_openid[:10]}... 的发送结果: {result}") 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: if rate_limited > 5:
return {'success': True, 'msg': '发送成功', 'msgid': result.get('msgid')} time.sleep(1.5)
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}
except Exception as e: logger.info(f'广播完成 订单={order_id} 成功={success} 失败={fail} 总计={total}')
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}")
return { return {
'success': True, 'success': True,
'msg': f'广播完成: 成功{success_count}, 失败{fail_count}', 'total': total,
'total': total_users, 'success_count': success,
'success_count': success_count, 'fail_count': fail,
'fail_count': fail_count }
}
def broadcast_order(self, order_info):
return self.broadcast_parallel(order_info)