From 4f102eb26b402858d6aa69a0b02c4d961324cfbb Mon Sep 17 00:00:00 2001 From: wanjia Date: Tue, 20 May 2025 12:17:45 +0800 Subject: [PATCH] =?UTF-8?q?ws=E6=9B=B4=E6=96=B0=E8=BE=BE=E4=BA=BA=E4=B8=8E?= =?UTF-8?q?=E6=B4=BB=E5=8A=A8=E7=9A=84=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/brands/consumers.py | 182 ++++++++++++++++++ apps/brands/routing.py | 7 + apps/brands/services/__init__.py | 2 +- apps/brands/services/offer_status_service.py | 163 ++++++++++++++++ .../brands/services/status_polling_service.py | 101 ++++++++++ apps/brands/views.py | 98 ++++++++++ daren/asgi.py | 24 ++- daren/settings.py | 26 ++- requirements.txt | Bin 9446 -> 1646 bytes 9 files changed, 593 insertions(+), 10 deletions(-) create mode 100644 apps/brands/consumers.py create mode 100644 apps/brands/routing.py create mode 100644 apps/brands/services/offer_status_service.py create mode 100644 apps/brands/services/status_polling_service.py diff --git a/apps/brands/consumers.py b/apps/brands/consumers.py new file mode 100644 index 0000000..b9da3eb --- /dev/null +++ b/apps/brands/consumers.py @@ -0,0 +1,182 @@ +import json +import logging +from channels.generic.websocket import WebsocketConsumer +from channels.layers import get_channel_layer +from asgiref.sync import async_to_sync +from .services.status_polling_service import polling_service +from .models import Campaign +from apps.daren_detail.models import CreatorCampaign, CreatorProfile + +logger = logging.getLogger('brands') + +class CampaignStatusConsumer(WebsocketConsumer): + """处理活动状态更新的WebSocket消费者""" + + def connect(self): + """处理WebSocket连接请求""" + # 获取活动ID从URL路由参数 + self.campaign_id = self.scope['url_route']['kwargs']['campaign_id'] + self.group_name = f'campaign_{self.campaign_id}' + + # 将连接添加到频道组 + async_to_sync(self.channel_layer.group_add)( + self.group_name, + self.channel_name + ) + + # 接受WebSocket连接 + self.accept() + + # 发送初始状态 + self.send_initial_status() + + # 启动轮询 + self.start_status_polling() + + logger.info(f"WebSocket连接已建立: {self.group_name}") + + def disconnect(self, close_code): + """处理WebSocket断开连接""" + # 将连接从频道组移除 + async_to_sync(self.channel_layer.group_discard)( + self.group_name, + self.channel_name + ) + + logger.info(f"WebSocket连接已断开: {self.group_name}, 关闭代码: {close_code}") + + def receive(self, text_data): + """处理从WebSocket客户端接收的消息""" + try: + # 解析接收到的JSON数据 + data = json.loads(text_data) + action = data.get('action') + + # 处理刷新请求 + if action == 'refresh': + self.send_initial_status() + + logger.debug(f"接收到WebSocket消息: {text_data}") + + except json.JSONDecodeError: + logger.error(f"接收到无效的JSON数据: {text_data}") + except Exception as e: + logger.error(f"处理WebSocket消息时出错: {str(e)}") + + def send_update(self, event): + """向WebSocket客户端发送更新消息""" + # 直接转发消息 + self.send(text_data=event['message']) + + def get_creator_data(self): + """获取创作者数据列表""" + try: + # 查询活动信息 + campaign = Campaign.objects.get(id=self.campaign_id) + + # 查询活动关联的达人 + creator_campaigns = CreatorCampaign.objects.filter( + campaign_id=campaign.id + ).select_related('creator') + + # 构建达人列表数据 + creator_list = [] + for cc in creator_campaigns: + creator = cc.creator + + # 格式化粉丝数和观看量 + followers_formatted = f"{int(creator.followers / 1000)}k" if creator.followers else "0" + avg_views_formatted = f"{int(creator.avg_video_views / 1000)}k" if creator.avg_video_views else "0" + + # 构建响应数据 + creator_data = { + "id": str(creator.id), + "name": creator.name, + "avatar": creator.avatar_url, + "category": creator.category, + "followers": followers_formatted, + "views": avg_views_formatted, + "gmv": f"${creator.gmv}k" if creator.gmv else "$0", + "pricing": f"${creator.pricing_min}" if creator.pricing_min else "$0", + "status": cc.status + } + creator_list.append(creator_data) + + return creator_list + except Exception as e: + logger.error(f"获取创作者数据出错: {str(e)}") + return [] + + def send_initial_status(self): + """发送初始状态信息""" + try: + # 获取创作者数据 + creator_list = self.get_creator_data() + + # 构建并发送标准格式消息 + message = { + 'code': 200, + 'message': '获取成功', + 'data': creator_list + } + + self.send(text_data=json.dumps(message)) + + except Campaign.DoesNotExist: + logger.error(f"找不到活动: {self.campaign_id}") + message = { + 'code': 404, + 'message': '找不到活动', + 'data': None + } + self.send(text_data=json.dumps(message)) + + except Exception as e: + logger.error(f"发送初始状态时出错: {str(e)}") + message = { + 'code': 500, + 'message': f'服务器错误: {str(e)}', + 'data': None + } + self.send(text_data=json.dumps(message)) + + def start_status_polling(self): + """启动状态轮询""" + try: + # 查询活动信息 + campaign = Campaign.objects.get(id=self.campaign_id) + + # 查询活动关联的达人 + creator_campaigns = CreatorCampaign.objects.filter( + campaign_id=campaign.id + ).select_related('creator') + + # 获取产品ID + product_id = None + if campaign.link_product.exists(): + product = campaign.link_product.first() + product_id = product.id + + # 如果没有关联产品,则使用活动ID作为产品ID + if not product_id: + product_id = campaign.id + + # 构建达人-产品对 + creator_product_pairs = [] + for cc in creator_campaigns: + creator_id = cc.creator_id + creator_product_pairs.append((creator_id, product_id)) + + # 启动轮询 + if creator_product_pairs: + polling_service.start_polling( + campaign_id=self.campaign_id, + creator_product_pairs=creator_product_pairs, + interval=30 # 每30秒轮询一次 + ) + logger.info(f"已启动活动 {self.campaign_id} 的状态轮询") + + except Campaign.DoesNotExist: + logger.error(f"找不到活动: {self.campaign_id}") + except Exception as e: + logger.error(f"启动状态轮询时出错: {str(e)}") \ No newline at end of file diff --git a/apps/brands/routing.py b/apps/brands/routing.py new file mode 100644 index 0000000..f1c4fd4 --- /dev/null +++ b/apps/brands/routing.py @@ -0,0 +1,7 @@ +from django.urls import re_path + +from . import consumers + +websocket_urlpatterns = [ + re_path(r'ws/campaigns/(?P\w+)/status/$', consumers.CampaignStatusConsumer.as_asgi()), +] \ No newline at end of file diff --git a/apps/brands/services/__init__.py b/apps/brands/services/__init__.py index 0519ecb..4725f9b 100644 --- a/apps/brands/services/__init__.py +++ b/apps/brands/services/__init__.py @@ -1 +1 @@ - \ No newline at end of file +# 服务模块初始化 \ No newline at end of file diff --git a/apps/brands/services/offer_status_service.py b/apps/brands/services/offer_status_service.py new file mode 100644 index 0000000..53851c4 --- /dev/null +++ b/apps/brands/services/offer_status_service.py @@ -0,0 +1,163 @@ +import requests +import logging +import json +from django.conf import settings +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer + +logger = logging.getLogger('brands') + +class OfferStatusService: + """提供获取达人谈判状态的服务""" + + @staticmethod + def fetch_status(creator_id, product_id): + """ + 获取达人对产品的谈判状态 + :param creator_id: 达人ID + :param product_id: 产品ID + :return: 状态字符串 + """ + try: + url = "http://127.0.0.1:8000/api/operation/negotiations/offer_status/" + + payload = { + 'creator_id': str(creator_id), + 'product_id': str(product_id) + } + + response = requests.post(url, data=payload) + + if response.status_code == 200: + data = response.json() + if data['code'] == 200: + return data['data']['status'] + else: + logger.error(f"获取谈判状态失败: {data['message']}") + return None + else: + logger.error(f"请求谈判状态接口失败: {response.status_code}") + return None + + except Exception as e: + logger.error(f"获取谈判状态时发生错误: {str(e)}") + return None + + @staticmethod + def update_creator_status(campaign_id, creator_id, status): + """ + 更新达人的状态 + :param campaign_id: 活动ID + :param creator_id: 达人ID + :param status: 新状态 + :return: 是否更新成功 + """ + try: + from apps.daren_detail.models import CreatorCampaign + + # 更新数据库中的状态 + creator_campaign = CreatorCampaign.objects.get( + campaign_id=campaign_id, + creator_id=creator_id + ) + + # 如果状态没有变化,则不进行更新 + if creator_campaign.status == status: + return False + + creator_campaign.status = status + creator_campaign.save() + logger.info(f"已更新数据库中的状态: 活动 {campaign_id}, 达人 {creator_id}, 状态 {status}") + return True + + except CreatorCampaign.DoesNotExist: + logger.error(f"找不到关联记录: 活动 {campaign_id}, 达人 {creator_id}") + return False + except Exception as e: + logger.error(f"更新达人状态时发生错误: {str(e)}") + return False + + @staticmethod + def get_campaign_creator_data(campaign_id): + """ + 获取活动关联的所有达人信息 + :param campaign_id: 活动ID + :return: 达人信息列表 + """ + try: + from apps.daren_detail.models import CreatorCampaign, CreatorProfile + + # 查询与活动关联的所有达人关联记录 + creator_campaigns = CreatorCampaign.objects.filter( + campaign_id=campaign_id + ).select_related('creator') + + creator_list = [] + for cc in creator_campaigns: + creator = cc.creator + + # 格式化粉丝数和观看量 + followers_formatted = f"{int(creator.followers / 1000)}k" if creator.followers else "0" + avg_views_formatted = f"{int(creator.avg_video_views / 1000)}k" if creator.avg_video_views else "0" + + # 构建响应数据 + creator_data = { + "id": str(creator.id), + "name": creator.name, + "avatar": creator.avatar_url, + "category": creator.category, + "followers": followers_formatted, + "views": avg_views_formatted, + "gmv": f"${creator.gmv}k" if creator.gmv else "$0", + "pricing": f"${creator.pricing_min}" if creator.pricing_min else "$0", + "status": cc.status + } + creator_list.append(creator_data) + + return creator_list + + except Exception as e: + logger.error(f"获取活动达人数据时发生错误: {str(e)}") + return [] + + @staticmethod + def send_status_update(campaign_id, creator_id, status): + """ + 通过WebSocket发送状态更新 + :param campaign_id: 活动ID + :param creator_id: 达人ID + :param status: 状态 + """ + try: + # 先更新数据库中的状态 + updated = OfferStatusService.update_creator_status(campaign_id, creator_id, status) + + # 如果状态没有变化,则不发送更新 + if not updated: + return + + # 获取最新的所有达人数据 + creator_list = OfferStatusService.get_campaign_creator_data(campaign_id) + + channel_layer = get_channel_layer() + + # 构建消息数据 - 使用标准的API响应格式 + message = { + 'code': 200, + 'message': '状态已更新', + 'data': creator_list + } + + # 发送到活动特定的群组 + async_to_sync(channel_layer.group_send)( + f'campaign_{campaign_id}', + { + 'type': 'send_update', + 'message': json.dumps(message) + } + ) + + logger.info(f"已发送状态更新: 活动 {campaign_id}, 达人 {creator_id}, 状态 {status}") + + except Exception as e: + logger.error(f"发送WebSocket更新失败: {str(e)}") \ No newline at end of file diff --git a/apps/brands/services/status_polling_service.py b/apps/brands/services/status_polling_service.py new file mode 100644 index 0000000..a987e91 --- /dev/null +++ b/apps/brands/services/status_polling_service.py @@ -0,0 +1,101 @@ +import threading +import time +import logging +from django.db import close_old_connections +from .offer_status_service import OfferStatusService + +logger = logging.getLogger('brands') + +class StatusPollingService: + """提供定时轮询服务,定期获取并更新达人状态""" + + def __init__(self): + self._polling_threads = {} # 保存活动ID到线程的映射 + self._stop_events = {} # 保存活动ID到停止事件的映射 + + def start_polling(self, campaign_id, creator_product_pairs, interval=30): + """ + 开始轮询指定活动的达人状态 + :param campaign_id: 活动ID + :param creator_product_pairs: 达人ID和产品ID的对应关系列表,格式为 [(creator_id, product_id), ...] + :param interval: 轮询间隔时间(秒) + """ + # 如果该活动已有轮询线程,先停止它 + if campaign_id in self._polling_threads: + self.stop_polling(campaign_id) + + # 创建停止事件 + stop_event = threading.Event() + self._stop_events[campaign_id] = stop_event + + # 创建并启动轮询线程 + thread = threading.Thread( + target=self._polling_worker, + args=(campaign_id, creator_product_pairs, interval, stop_event), + daemon=True + ) + self._polling_threads[campaign_id] = thread + thread.start() + + logger.info(f"已启动活动 {campaign_id} 的状态轮询,间隔 {interval} 秒") + + def stop_polling(self, campaign_id): + """ + 停止指定活动的轮询 + :param campaign_id: 活动ID + """ + if campaign_id in self._stop_events: + # 设置停止事件 + self._stop_events[campaign_id].set() + + # 等待线程结束 + if campaign_id in self._polling_threads: + self._polling_threads[campaign_id].join(timeout=5) + + # 清理资源 + del self._polling_threads[campaign_id] + + del self._stop_events[campaign_id] + logger.info(f"已停止活动 {campaign_id} 的状态轮询") + + def stop_all(self): + """停止所有轮询""" + campaign_ids = list(self._polling_threads.keys()) + for campaign_id in campaign_ids: + self.stop_polling(campaign_id) + + def _polling_worker(self, campaign_id, creator_product_pairs, interval, stop_event): + """ + 轮询工作线程 + :param campaign_id: 活动ID + :param creator_product_pairs: 达人ID和产品ID的对应关系列表 + :param interval: 轮询间隔(秒) + :param stop_event: 停止事件 + """ + while not stop_event.is_set(): + try: + # 关闭旧的数据库连接 + close_old_connections() + + # 遍历每个达人-产品对,获取并发送状态更新 + for creator_id, product_id in creator_product_pairs: + try: + # 获取状态 + status = OfferStatusService.fetch_status(creator_id, product_id) + + if status: + # 发送状态更新 + OfferStatusService.send_status_update(campaign_id, creator_id, status) + except Exception as e: + logger.error(f"处理达人 {creator_id} 状态时出错: {str(e)}") + + # 等待指定时间间隔 + stop_event.wait(interval) + + except Exception as e: + logger.error(f"轮询线程发生错误: {str(e)}") + # 短暂休眠后继续 + time.sleep(5) + +# 创建单例实例 +polling_service = StatusPollingService() \ No newline at end of file diff --git a/apps/brands/views.py b/apps/brands/views.py index 4a282fc..16aa871 100644 --- a/apps/brands/views.py +++ b/apps/brands/views.py @@ -2,6 +2,7 @@ from django.shortcuts import render, get_object_or_404 from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response +import logging from .models import Brand, Product, Campaign, BrandChatSession from .serializers import ( @@ -11,6 +12,10 @@ from .serializers import ( BrandChatSessionSerializer, BrandDetailSerializer ) +from .services.status_polling_service import polling_service +from .services.offer_status_service import OfferStatusService + +logger = logging.getLogger(__name__) def api_response(code=200, message="成功", data=None): """统一API响应格式""" @@ -269,6 +274,99 @@ class CampaignViewSet(viewsets.ModelViewSet): except Exception as e: return api_response(code=500, message=f"移除产品失败: {str(e)}", data=None) + @action(detail=True, methods=['get']) + def creator_list(self, request, pk=None): + """获取活动关联的达人列表""" + campaign = self.get_object() + from apps.daren_detail.models import CreatorCampaign, CreatorProfile + + # 获取所有达人数据 + creator_list = OfferStatusService.get_campaign_creator_data(campaign.id) + + # 启动状态轮询(当有用户请求此接口时) + try: + # 获取产品ID + product_id = None + if campaign.link_product.exists(): + product = campaign.link_product.first() + product_id = product.id + + # 如果没有关联产品,则使用活动ID作为产品ID + if not product_id: + product_id = campaign.id + + # 构建达人-产品对 + creator_product_pairs = [] + for creator_data in creator_list: + creator_id = creator_data['id'] + creator_product_pairs.append((creator_id, product_id)) + + # 启动轮询 + if creator_product_pairs: + polling_service.start_polling( + campaign_id=campaign.id, + creator_product_pairs=creator_product_pairs, + interval=30 # 每30秒轮询一次 + ) + except Exception as e: + logger.error(f"启动状态轮询时出错: {str(e)}") + + return api_response(data=creator_list) + + @action(detail=True, methods=['post']) + def update_creator_status(self, request, pk=None): + """手动更新达人状态""" + campaign = self.get_object() + from apps.daren_detail.models import CreatorCampaign + from .services.offer_status_service import OfferStatusService + + # 获取传入的达人ID + creator_id = request.data.get('creator_id') + + if not creator_id: + return api_response(code=400, message="缺少必要参数: creator_id", data=None) + + try: + # 查询达人与活动的关联 + creator_campaign = CreatorCampaign.objects.get( + campaign_id=campaign.id, + creator_id=creator_id + ) + + # 获取产品ID + product_id = None + if campaign.link_product.exists(): + product = campaign.link_product.first() + product_id = product.id + + # 如果没有关联产品,则使用活动ID作为产品ID + if not product_id: + product_id = campaign.id + + # 获取最新状态 + status = OfferStatusService.fetch_status(creator_id, product_id) + + if status: + # 更新状态 + creator_campaign.status = status + creator_campaign.save() + + # 获取所有达人的最新数据 + creator_list = OfferStatusService.get_campaign_creator_data(campaign.id) + + # 发送WebSocket更新 + OfferStatusService.send_status_update(campaign.id, creator_id, status) + + return api_response(message="状态已更新", data=creator_list) + else: + return api_response(code=500, message="获取状态失败", data=None) + + except CreatorCampaign.DoesNotExist: + return api_response(code=404, message="找不到达人与活动的关联", data=None) + except Exception as e: + logger.error(f"更新达人状态时出错: {str(e)}") + return api_response(code=500, message=f"更新状态失败: {str(e)}", data=None) + class BrandChatSessionViewSet(viewsets.ModelViewSet): """品牌聊天会话API视图集""" diff --git a/daren/asgi.py b/daren/asgi.py index 93a365d..38f9ff0 100644 --- a/daren/asgi.py +++ b/daren/asgi.py @@ -1,16 +1,26 @@ """ ASGI config for daren project. - -It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ """ import os +import django + +# 设置Django设置模块 +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'daren.settings') +django.setup() # 添加这一行 from django.core.asgi import get_asgi_application +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.auth import AuthMiddlewareStack -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'daren.settings') +# 确保在django.setup()之后再导入 +import apps.brands.routing -application = get_asgi_application() +application = ProtocolTypeRouter({ + 'http': get_asgi_application(), + 'websocket': AuthMiddlewareStack( + URLRouter( + apps.brands.routing.websocket_urlpatterns + ) + ), +}) diff --git a/daren/settings.py b/daren/settings.py index ac6f466..dfd2364 100644 --- a/daren/settings.py +++ b/daren/settings.py @@ -15,6 +15,8 @@ import os # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent +import pymysql +pymysql.install_as_MySQLdb() # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ @@ -38,6 +40,8 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django_filters', 'django.contrib.staticfiles', + 'channels', + 'rest_framework', 'apps.user.apps.UserConfig', "apps.expertproducts.apps.ExpertproductsConfig", "apps.daren_detail.apps.DarenDetailConfig", @@ -77,6 +81,14 @@ TEMPLATES = [ ] WSGI_APPLICATION = 'daren.wsgi.application' +ASGI_APPLICATION = 'daren.asgi.application' + +# WebSocket配置 +CHANNEL_LAYERS = { + 'default': { + 'BACKEND': 'channels.layers.InMemoryChannelLayer', + }, +} # Database @@ -93,9 +105,9 @@ DATABASES = { 'OPTIONS': { 'charset': 'utf8mb4', 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'", - 'connect_timeout': 60, # 连接超时时间 + 'connect_timeout': 60, }, - 'CONN_MAX_AGE': 0, # 强制Django在每次请求后关闭连接 + 'CONN_MAX_AGE': 0, } } @@ -172,5 +184,15 @@ LOGGING = { 'level': 'INFO', 'propagate': True, }, + 'daren_detail': { + 'handlers': ['console', 'file'], + 'level': 'INFO', + 'propagate': True, + }, + 'brands': { + 'handlers': ['console', 'file'], + 'level': 'INFO', + 'propagate': True, + }, }, } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 0fb7d291ee285628ed5372df1428f626d3401302..b11861794e368067348437b0b7ec2107475527e5 100644 GIT binary patch delta 393 zcmX|7O)mpc6ur~5op$ttR%7}x6U77(bUvAdCN>gVI~$3lP1>M@u@QbkMVudCYh`W0 z(t@8L)?&rp0{1;lb90k-a_@QP-1ENqnTh@Wpr!PR5k+)NC$z@pkWWkG;v%*_B3>T1 zO#znU#t=J1# zD&jg-P<*KAPzlga8`Y2}AqjSe)osZWKME($SxX5~%vre1HPOt^VXI`~VPb+49&Yp!KJlFQh^UfIH3xrXzYhVM!aZxs`j+9+PDRTU5Y G8TSvK1X3pe literal 9446 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