From 0bcd8822dc050a8c0a44d3f5c0a00c7fba800a2e Mon Sep 17 00:00:00 2001 From: wanjia Date: Tue, 20 May 2025 15:57:10 +0800 Subject: [PATCH] =?UTF-8?q?=E8=8E=B7=E5=8F=96=E6=8E=A8=E9=80=81=E7=9A=84gm?= =?UTF-8?q?ail=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/0006_delete_usergoal.py | 16 + apps/accounts/models.py | 17 - apps/accounts/serializers.py | 15 +- apps/accounts/services/goal_service.py | 102 -- apps/accounts/urls.py | 4 - apps/accounts/views.py | 278 +---- apps/brands/views.py | 5 - .../0002_alter_chathistory_content.py | 18 + apps/discovery/README.md | 329 +++++ apps/discovery/__init__.py | 3 + apps/discovery/admin.py | 18 + apps/discovery/apps.py | 7 + apps/discovery/exceptions.py | 31 + apps/discovery/management/__init__.py | 3 + .../discovery/management/commands/__init__.py | 3 + .../commands/create_mock_discovery_data.py | 73 ++ apps/discovery/migrations/0001_initial.py | 57 + .../0002_alter_searchsession_date_created.py | 19 + apps/discovery/migrations/__init__.py | 0 apps/discovery/models.py | 79 ++ apps/discovery/pagination.py | 22 + apps/discovery/serializers.py | 35 + apps/discovery/tests.py | 3 + apps/discovery/urls.py | 12 + apps/discovery/views.py | 276 +++++ apps/feishu/migrations/0002_feishuauth.py | 39 + ...03_feishutablemapping_delete_feishuauth.py | 35 + apps/feishu/models.py | 24 +- apps/feishu/serializers.py | 13 + .../auto_gmail_conversation_service.py | 923 ++++++++++++++ apps/feishu/services/bitable_service.py | 216 ++++ apps/feishu/services/data_sync_service.py | 346 ++++++ apps/feishu/services/feishu_service.py | 1 + .../services/gmail_extraction_service.py | 153 +++ apps/feishu/urls.py | 20 + apps/feishu/views.py | 467 +++++++- apps/gmail/migrations/0005_usergoal.py | 34 + ...ion_time_usergoal_conversation_and_more.py | 39 + ...007_alter_gmailattachment_attachment_id.py | 18 + ...008_alter_gmailattachment_attachment_id.py | 18 + ...conversation_has_sent_greeting_and_more.py | 82 ++ apps/gmail/models.py | 93 +- apps/gmail/serializers.py | 144 ++- apps/gmail/services/gmail_service.py | 1061 +++++++++-------- apps/gmail/services/goal_service.py | 217 ++++ apps/gmail/urls.py | 21 +- apps/gmail/views.py | 713 +++++++++-- apps/operation/README.md | 527 ++++++++ apps/operation/admin.py | 46 +- apps/operation/migrations/0001_initial.py | 8 +- ..._alter_platformaccount_options_and_more.py | 59 + apps/operation/models.py | 63 +- apps/operation/pagination.py | 8 + apps/operation/serializers.py | 151 ++- apps/operation/views.py | 732 ++++-------- apps/template/admin.py | 0 apps/template/apps.py | 7 + apps/template/exceptions.py | 59 + apps/template/filters.py | 26 + apps/template/migrations/0001_initial.py | 53 + apps/template/migrations/__init__.py | 0 apps/template/models.py | 77 ++ apps/template/pagination.py | 37 + apps/template/serializers.py | 104 ++ apps/template/tests.py | 3 + apps/template/urls.py | 11 + apps/template/utils.py | 36 + apps/template/views.py | 297 +++++ daren_project/settings.py | 15 +- daren_project/urls.py | 6 +- requirements.txt | Bin 1800 -> 4108 bytes 71 files changed, 6660 insertions(+), 1767 deletions(-) create mode 100644 apps/accounts/migrations/0006_delete_usergoal.py delete mode 100644 apps/accounts/services/goal_service.py create mode 100644 apps/chat/migrations/0002_alter_chathistory_content.py create mode 100644 apps/discovery/README.md create mode 100644 apps/discovery/__init__.py create mode 100644 apps/discovery/admin.py create mode 100644 apps/discovery/apps.py create mode 100644 apps/discovery/exceptions.py create mode 100644 apps/discovery/management/__init__.py create mode 100644 apps/discovery/management/commands/__init__.py create mode 100644 apps/discovery/management/commands/create_mock_discovery_data.py create mode 100644 apps/discovery/migrations/0001_initial.py create mode 100644 apps/discovery/migrations/0002_alter_searchsession_date_created.py create mode 100644 apps/discovery/migrations/__init__.py create mode 100644 apps/discovery/models.py create mode 100644 apps/discovery/pagination.py create mode 100644 apps/discovery/serializers.py create mode 100644 apps/discovery/tests.py create mode 100644 apps/discovery/urls.py create mode 100644 apps/discovery/views.py create mode 100644 apps/feishu/migrations/0002_feishuauth.py create mode 100644 apps/feishu/migrations/0003_feishutablemapping_delete_feishuauth.py create mode 100644 apps/feishu/services/auto_gmail_conversation_service.py create mode 100644 apps/feishu/services/bitable_service.py create mode 100644 apps/feishu/services/data_sync_service.py create mode 100644 apps/feishu/services/feishu_service.py create mode 100644 apps/feishu/services/gmail_extraction_service.py create mode 100644 apps/gmail/migrations/0005_usergoal.py create mode 100644 apps/gmail/migrations/0006_usergoal_completion_time_usergoal_conversation_and_more.py create mode 100644 apps/gmail/migrations/0007_alter_gmailattachment_attachment_id.py create mode 100644 apps/gmail/migrations/0008_alter_gmailattachment_attachment_id.py create mode 100644 apps/gmail/migrations/0009_gmailconversation_has_sent_greeting_and_more.py create mode 100644 apps/gmail/services/goal_service.py create mode 100644 apps/operation/README.md create mode 100644 apps/operation/migrations/0002_alter_platformaccount_options_and_more.py create mode 100644 apps/template/admin.py create mode 100644 apps/template/apps.py create mode 100644 apps/template/exceptions.py create mode 100644 apps/template/filters.py create mode 100644 apps/template/migrations/0001_initial.py create mode 100644 apps/template/migrations/__init__.py create mode 100644 apps/template/models.py create mode 100644 apps/template/pagination.py create mode 100644 apps/template/serializers.py create mode 100644 apps/template/tests.py create mode 100644 apps/template/urls.py create mode 100644 apps/template/utils.py create mode 100644 apps/template/views.py diff --git a/apps/accounts/migrations/0006_delete_usergoal.py b/apps/accounts/migrations/0006_delete_usergoal.py new file mode 100644 index 0000000..3890ac8 --- /dev/null +++ b/apps/accounts/migrations/0006_delete_usergoal.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2 on 2025-05-14 02:52 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0005_usergoal'), + ] + + operations = [ + migrations.DeleteModel( + name='UserGoal', + ), + ] diff --git a/apps/accounts/models.py b/apps/accounts/models.py index 1eb5748..64bcba9 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -99,20 +99,3 @@ class UserProfile(models.Model): def __str__(self): return f"{self.user.username}的个人资料" -class UserGoal(models.Model): - """用户目标模型 - 存储用户设定的沟通或销售目标""" - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='goals') - description = models.TextField(verbose_name='目标描述') - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - is_active = models.BooleanField(default=True, verbose_name='是否激活') - - class Meta: - db_table = 'user_goals' - verbose_name = '用户目标' - verbose_name_plural = '用户目标' - ordering = ['-updated_at'] - - def __str__(self): - return f"{self.user.username}的目标" diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 1e929c1..ae36e54 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from apps.accounts.models import User, UserProfile, UserGoal +from apps.accounts.models import User, UserProfile class UserProfileSerializer(serializers.ModelSerializer): """用户档案序列化器""" @@ -63,16 +63,3 @@ class PasswordChangeSerializer(serializers.Serializer): return value -class UserGoalSerializer(serializers.ModelSerializer): - """用户目标序列化器""" - - class Meta: - model = UserGoal - fields = ['id', 'description', 'created_at', 'updated_at', 'is_active'] - read_only_fields = ['id', 'created_at', 'updated_at'] - - def create(self, validated_data): - """创建新目标时自动关联当前用户""" - user = self.context['request'].user - goal = UserGoal.objects.create(user=user, **validated_data) - return goal \ No newline at end of file diff --git a/apps/accounts/services/goal_service.py b/apps/accounts/services/goal_service.py deleted file mode 100644 index f59b042..0000000 --- a/apps/accounts/services/goal_service.py +++ /dev/null @@ -1,102 +0,0 @@ -import logging -from django.conf import settings -from datetime import datetime -from apps.accounts.models import UserGoal -from apps.gmail.models import GmailConversation, ConversationSummary -from apps.chat.models import ChatHistory -from apps.common.services.ai_service import AIService - -logger = logging.getLogger(__name__) - -def get_active_goal(user): - """ - 获取用户最新的活跃目标 - - Args: - user: 用户对象 - - Returns: - UserGoal: 用户目标对象或None - """ - return UserGoal.objects.filter(user=user, is_active=True).order_by('-updated_at').first() - -def get_conversation_summary(conversation_id): - """ - 获取对话摘要 - - Args: - conversation_id: 对话ID - - Returns: - str: 摘要内容或None - """ - try: - # 先检查持久化存储的摘要 - try: - conversation = GmailConversation.objects.get(conversation_id=conversation_id) - summary = ConversationSummary.objects.get(conversation=conversation) - return summary.content - except (GmailConversation.DoesNotExist, ConversationSummary.DoesNotExist): - pass - - # 如果没有持久化的摘要,尝试生成简单摘要 - chat_history = ChatHistory.objects.filter(conversation_id=conversation_id).order_by('-created_at')[:5] - if not chat_history: - return None - - # 生成简单摘要(最近几条消息) - messages = [] - for msg in chat_history: - if len(messages) < 3: # 只取最新的3条 - role = "用户" if msg.role == "user" else "达人" - content = msg.content - if len(content) > 100: - content = content[:100] + "..." - messages.append(f"{role}: {content}") - - if messages: - return "最近对话: " + " | ".join(reversed(messages)) - return None - except Exception as e: - logger.error(f"获取对话摘要失败: {str(e)}") - return None - -def get_last_message(conversation_id): - """ - 获取对话中最后一条对方发送的消息 - - Args: - conversation_id: 对话ID - - Returns: - str: 最后一条消息内容或None - """ - try: - # 获取对话中最后一条对方(达人)发送的消息 - last_message = ChatHistory.objects.filter( - conversation_id=conversation_id, - role='assistant' # 达人的消息 - ).order_by('-created_at').first() - - if last_message: - return last_message.content - return None - except Exception as e: - logger.error(f"获取最后一条消息失败: {str(e)}") - return None - -def generate_recommended_reply(user, goal_description, conversation_summary, last_message): - """ - 根据用户目标、对话摘要和最后一条消息生成推荐话术 - - Args: - user: 用户对象 - goal_description: 用户目标描述 - conversation_summary: 对话摘要 - last_message: 达人最后发送的消息内容 - - Returns: - tuple: (推荐话术内容, 错误信息) - """ - # 直接调用AIService生成回复 - return AIService.generate_email_reply(goal_description, conversation_summary, last_message) \ No newline at end of file diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py index 38538e0..4ef0565 100644 --- a/apps/accounts/urls.py +++ b/apps/accounts/urls.py @@ -4,7 +4,6 @@ from apps.accounts.views import ( LoginView, RegisterView, LogoutView, user_profile, change_password, user_detail, user_update, user_delete, verify_token, user_list ) -from .views import UserGoalView, UserGoalDetailView, RecommendedReplyView urlpatterns = [ path('login/', LoginView.as_view(), name='login'), @@ -17,7 +16,4 @@ urlpatterns = [ path('users//', user_detail, name='user_detail'), path('users//update/', user_update, name='user_update'), path('users//delete/', user_delete, name='user_delete'), - path('goals/', UserGoalView.as_view(), name='user_goals'), - path('goals//', UserGoalDetailView.as_view(), name='user_goal_detail'), - path('recommended-reply/', RecommendedReplyView.as_view(), name='recommended_reply'), ] diff --git a/apps/accounts/views.py b/apps/accounts/views.py index 86305eb..821648e 100644 --- a/apps/accounts/views.py +++ b/apps/accounts/views.py @@ -15,18 +15,14 @@ from django.shortcuts import get_object_or_404 import uuid import logging import traceback -from apps.accounts.models import User, UserGoal +from apps.accounts.models import User from apps.accounts.services.auth_service import ( authenticate_user, create_user, generate_token, delete_token ) from apps.accounts.services.utils import ( - convert_to_uuid, format_user_response, validate_uuid_param + format_user_response, validate_uuid_param ) -from apps.accounts.services.goal_service import ( - generate_recommended_reply, get_active_goal, get_conversation_summary, - get_last_message -) -from .serializers import UserGoalSerializer + logger = logging.getLogger(__name__) @@ -591,271 +587,3 @@ def user_list(request): 'data': None }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) -class UserGoalView(APIView): - """ - 用户目标管理API - """ - permission_classes = [IsAuthenticated] - - def get(self, request): - """获取当前用户的所有目标""" - try: - goals = UserGoal.objects.filter(user=request.user, is_active=True) - serializer = UserGoalSerializer(goals, many=True) - return Response({ - 'code': 200, - 'message': '获取目标列表成功', - 'data': serializer.data - }) - except Exception as e: - logger.error(f"获取用户目标失败: {str(e)}") - logger.error(traceback.format_exc()) - return Response({ - 'code': 500, - 'message': f'获取用户目标失败: {str(e)}', - 'data': None - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - def post(self, request): - """创建新的用户目标""" - try: - serializer = UserGoalSerializer(data=request.data, context={'request': request}) - if serializer.is_valid(): - serializer.save() - return Response({ - 'code': 201, - 'message': '目标创建成功', - 'data': serializer.data - }, status=status.HTTP_201_CREATED) - - return Response({ - 'code': 400, - 'message': '创建目标失败', - 'data': serializer.errors - }, status=status.HTTP_400_BAD_REQUEST) - except Exception as e: - logger.error(f"创建用户目标失败: {str(e)}") - logger.error(traceback.format_exc()) - return Response({ - 'code': 500, - 'message': f'创建用户目标失败: {str(e)}', - 'data': None - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - -class UserGoalDetailView(APIView): - """ - 用户目标详情API - """ - permission_classes = [IsAuthenticated] - - def get_object(self, goal_id, user): - """获取指定的用户目标""" - try: - # 验证UUID格式 - uuid_obj, error_response = validate_uuid_param(goal_id) - if error_response: - return None - - return UserGoal.objects.get(id=uuid_obj, user=user) - except UserGoal.DoesNotExist: - return None - - def get(self, request, goal_id): - """获取单个目标详情""" - try: - goal = self.get_object(goal_id, request.user) - if not goal: - return Response({ - 'code': 404, - 'message': '目标不存在', - 'data': None - }, status=status.HTTP_404_NOT_FOUND) - - serializer = UserGoalSerializer(goal) - return Response({ - 'code': 200, - 'message': '获取目标详情成功', - 'data': serializer.data - }) - except Exception as e: - logger.error(f"获取目标详情失败: {str(e)}") - logger.error(traceback.format_exc()) - return Response({ - 'code': 500, - 'message': f'获取目标详情失败: {str(e)}', - 'data': None - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - def put(self, request, goal_id): - """更新目标信息""" - try: - goal = self.get_object(goal_id, request.user) - if not goal: - return Response({ - 'code': 404, - 'message': '目标不存在', - 'data': None - }, status=status.HTTP_404_NOT_FOUND) - - serializer = UserGoalSerializer(goal, data=request.data) - if serializer.is_valid(): - serializer.save() - return Response({ - 'code': 200, - 'message': '目标更新成功', - 'data': serializer.data - }) - - return Response({ - 'code': 400, - 'message': '更新目标失败', - 'data': serializer.errors - }, status=status.HTTP_400_BAD_REQUEST) - except Exception as e: - logger.error(f"更新目标失败: {str(e)}") - logger.error(traceback.format_exc()) - return Response({ - 'code': 500, - 'message': f'更新目标失败: {str(e)}', - 'data': None - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - def delete(self, request, goal_id): - """删除目标""" - try: - goal = self.get_object(goal_id, request.user) - if not goal: - return Response({ - 'code': 404, - 'message': '目标不存在', - 'data': None - }, status=status.HTTP_404_NOT_FOUND) - - # 软删除: 将状态设置为非活跃 - goal.is_active = False - goal.save() - - return Response({ - 'code': 200, - 'message': '目标删除成功', - 'data': None - }) - except Exception as e: - logger.error(f"删除目标失败: {str(e)}") - logger.error(traceback.format_exc()) - return Response({ - 'code': 500, - 'message': f'删除目标失败: {str(e)}', - 'data': None - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - -class RecommendedReplyView(APIView): - """ - 基于用户目标生成推荐回复话术的API - """ - permission_classes = [IsAuthenticated] - - def post(self, request): - """ - 生成推荐回复话术 - - 请求参数: - - goal_id: 目标ID (可选,如不提供则使用用户当前活跃目标) - - conversation_id: 对话ID (可选,用于获取对话摘要和最后一条消息) - - conversation_summary: 对话摘要 (可选,如提供了conversation_id则优先使用对话ID获取摘要) - - last_message: 达人最后发送的消息内容 (可选,如提供了conversation_id则可以自动获取) - - 响应: - - 推荐的回复话术 - """ - try: - # 获取请求参数 - goal_id = request.data.get('goal_id') - conversation_id = request.data.get('conversation_id') - conversation_summary = request.data.get('conversation_summary', '') - last_message = request.data.get('last_message') - - # 如果提供了对话ID,尝试获取对话摘要和最后一条消息 - if conversation_id: - # 获取对话摘要 - stored_summary = get_conversation_summary(conversation_id) - if stored_summary: - conversation_summary = stored_summary - - # 如果没有提供last_message,尝试从对话中获取 - if not last_message: - last_message = get_last_message(conversation_id) - - # 验证必填参数 - if not last_message: - return Response({ - 'code': 400, - 'message': '缺少必要参数: last_message,且无法从对话ID自动获取最后一条消息', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 获取用户目标 - if goal_id: - # 如果提供了目标ID,则获取该目标 - uuid_obj, error_response = validate_uuid_param(goal_id) - if error_response: - return error_response - - try: - goal = UserGoal.objects.get(id=uuid_obj, user=request.user, is_active=True) - goal_description = goal.description - except UserGoal.DoesNotExist: - return Response({ - 'code': 404, - 'message': '目标不存在', - 'data': None - }, status=status.HTTP_404_NOT_FOUND) - else: - # 否则使用用户最近的活跃目标 - goal = get_active_goal(request.user) - - if not goal: - return Response({ - 'code': 404, - 'message': '未找到活跃目标,请先设置目标', - 'data': None - }, status=status.HTTP_404_NOT_FOUND) - - goal_description = goal.description - - # 生成推荐回复 - reply_content, error = generate_recommended_reply( - request.user, - goal_description, - conversation_summary, - last_message - ) - - if error: - return Response({ - 'code': 500, - 'message': f'生成推荐回复失败: {error}', - 'data': None - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - return Response({ - 'code': 200, - 'message': '推荐回复生成成功', - 'data': { - 'goal_id': str(goal.id), - 'goal_description': goal_description, - 'recommended_reply': reply_content, - 'conversation_id': conversation_id - } - }) - - except Exception as e: - logger.error(f"生成推荐回复失败: {str(e)}") - logger.error(traceback.format_exc()) - return Response({ - 'code': 500, - 'message': f'服务器错误: {str(e)}', - 'data': None - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - \ No newline at end of file diff --git a/apps/brands/views.py b/apps/brands/views.py index a1ab38b..8ff3603 100644 --- a/apps/brands/views.py +++ b/apps/brands/views.py @@ -2,7 +2,6 @@ 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 -from rest_framework.permissions import IsAuthenticated from .models import Brand, Product, Campaign, BrandChatSession from .serializers import ( @@ -25,7 +24,6 @@ class BrandViewSet(viewsets.ModelViewSet): """品牌API视图集""" queryset = Brand.objects.all() serializer_class = BrandSerializer - permission_classes = [IsAuthenticated] def get_serializer_class(self): if self.action == 'retrieve': @@ -90,7 +88,6 @@ class ProductViewSet(viewsets.ModelViewSet): """产品API视图集""" queryset = Product.objects.filter(is_active=True) serializer_class = ProductSerializer - permission_classes = [IsAuthenticated] def list(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) @@ -166,7 +163,6 @@ class CampaignViewSet(viewsets.ModelViewSet): """活动API视图集""" queryset = Campaign.objects.filter(is_active=True) serializer_class = CampaignSerializer - permission_classes = [IsAuthenticated] def list(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) @@ -278,7 +274,6 @@ class BrandChatSessionViewSet(viewsets.ModelViewSet): """品牌聊天会话API视图集""" queryset = BrandChatSession.objects.filter(is_active=True) serializer_class = BrandChatSessionSerializer - permission_classes = [IsAuthenticated] def list(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) diff --git a/apps/chat/migrations/0002_alter_chathistory_content.py b/apps/chat/migrations/0002_alter_chathistory_content.py new file mode 100644 index 0000000..2087581 --- /dev/null +++ b/apps/chat/migrations/0002_alter_chathistory_content.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2025-05-15 10:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='chathistory', + name='content', + field=models.TextField(help_text='消息内容,支持存储长文本', max_length=65535), + ), + ] diff --git a/apps/discovery/README.md b/apps/discovery/README.md new file mode 100644 index 0000000..63d1938 --- /dev/null +++ b/apps/discovery/README.md @@ -0,0 +1,329 @@ +# Discovery API 接口文档 + +## 简介 + +Discovery API是一个用于发现和搜索创作者的接口。它提供了创作者搜索、搜索会话管理等功能。所有API响应均遵循统一格式: + +```json +{ + "code": 200, // 状态码,200表示成功 + "message": "操作成功", // 操作提示消息 + "data": { // 实际数据 + // 具体内容 + } +} +``` + +## 使用Apifox测试接口 + +1. 下载并安装Apifox: https://www.apifox.cn/ +2. 创建新项目,命名为"Creator Discovery" +3. 导入API或手动创建以下接口 + +## API 接口列表 + +### 1. 搜索创作者 + +- **URL**: `http://localhost:8000/api/discovery/creators/search/` +- **方法**: POST +- **描述**: 根据条件搜索创作者,并创建新的搜索会话 +- **请求参数**: + +```json +{ + "query": "创作者", + "category": "Health", + "ecommerce_level": "L5", + "exposure_level": "KOL-2" +} +``` + +- **响应示例**: + +```json +{ + "code": 200, + "message": "搜索创作者成功", + "data": { + "id": 4, + "creators": [ + { + "id": 37, + "name": "Mock Creator 5", + "avatar": null, + "category": "Health", + "ecommerce_level": "L5", + "exposure_level": "KOL-2", + "followers": 162.2, + "gmv": 534.1, + "items_sold": 18.1, + "avg_video_views": 1.9, + "has_ecommerce": false, + "tiktok_url": null, + "session": 4 + } + ], + "session_number": 4, + "creator_count": 1, + "shoppable_creators": 0, + "avg_followers": 162.2, + "avg_gmv": 534.1, + "avg_video_views": 1.9, + "date_created": "2023-10-02" + } +} +``` + +### 2. 获取搜索会话列表 + +- **URL**: `http://localhost:8000/api/discovery/sessions/` +- **方法**: GET +- **描述**: 获取所有搜索会话的历史记录 +- **查询参数**: + - `page`: 页码,默认为1 + - `page_size`: 每页条数,默认为20,最大为100 +- **响应示例**: + +```json +{ + "code": 200, + "message": "获取数据成功", + "data": { + "count": 3, + "next": "http://localhost:8000/api/discovery/sessions/?page=2", + "previous": null, + "results": [ + { + "id": 1, + "session_number": 1, + "creator_count": 42, + "shoppable_creators": 24, + "avg_followers": 162.2, + "avg_gmv": 534.1, + "avg_video_views": 1.9, + "date_created": "2024-01-06" + }, + { + "id": 2, + "session_number": 2, + "creator_count": 53, + "shoppable_creators": 13, + "avg_followers": 162.2, + "avg_gmv": 534.1, + "avg_video_views": 1.9, + "date_created": "2022-01-07" + } + ] + } +} +``` + +### 3. 获取搜索会话详情 + +- **URL**: `http://localhost:8000/api/discovery/sessions/{id}/` +- **方法**: GET +- **描述**: 获取特定搜索会话的详细信息,包含该会话中的所有创作者 +- **请求参数**: 路径参数 `id` +- **响应示例**: + +```json +{ + "code": 200, + "message": "获取搜索会话详情成功", + "data": { + "id": 1, + "creators": [ + { + "id": 1, + "name": "Creator 1 in Session 1", + "avatar": null, + "category": "Phones & Electronics", + "ecommerce_level": "L2", + "exposure_level": "KOC-1", + "followers": 162.2, + "gmv": 534.1, + "items_sold": 18.1, + "avg_video_views": 1.9, + "has_ecommerce": true, + "tiktok_url": null, + "session": 1 + }, + { + "id": 2, + "name": "Creator 9 in Session 1", + "avatar": null, + "category": "Womenswear & Underwear", + "ecommerce_level": "L3", + "exposure_level": "KOL-3", + "followers": 162.2, + "gmv": 534.1, + "items_sold": 18.1, + "avg_video_views": 1.9, + "has_ecommerce": false, + "tiktok_url": null, + "session": 1 + } + ], + "session_number": 1, + "creator_count": 42, + "shoppable_creators": 24, + "avg_followers": 162.2, + "avg_gmv": 534.1, + "avg_video_views": 1.9, + "date_created": "2024-01-06" + } +} +``` + +### 4. 获取会话中的创作者 + +- **URL**: `http://localhost:8000/api/discovery/sessions/{id}/results/` +- **方法**: GET +- **描述**: 获取特定会话中的所有创作者 +- **请求参数**: + - 路径参数 `id` + - 查询参数 `page`、`page_size`(分页参数) +- **响应示例**: + +```json +{ + "code": 200, + "message": "获取数据成功", + "data": { + "count": 42, + "next": "http://localhost:8000/api/discovery/sessions/1/results/?page=2", + "previous": null, + "results": [ + { + "id": 1, + "name": "Creator 1 in Session 1", + "avatar": null, + "category": "Phones & Electronics", + "ecommerce_level": "L2", + "exposure_level": "KOC-1", + "followers": 162.2, + "gmv": 534.1, + "items_sold": 18.1, + "avg_video_views": 1.9, + "has_ecommerce": true, + "tiktok_url": null, + "session": 1 + }, + { + "id": 2, + "name": "Creator 9 in Session 1", + "avatar": null, + "category": "Womenswear & Underwear", + "ecommerce_level": "L3", + "exposure_level": "KOL-3", + "followers": 162.2, + "gmv": 534.1, + "items_sold": 18.1, + "avg_video_views": 1.9, + "has_ecommerce": false, + "tiktok_url": null, + "session": 1 + } + ] + } +} +``` + +### 5. 获取所有创作者 + +- **URL**: `http://localhost:8000/api/discovery/creators/` +- **方法**: GET +- **描述**: 获取系统中的所有创作者 +- **请求参数**: 查询参数 `page`、`page_size`(分页参数) +- **响应示例**: + +```json +{ + "code": 200, + "message": "获取数据成功", + "data": { + "count": 150, + "next": "http://localhost:8000/api/discovery/creators/?page=2", + "previous": null, + "results": [ + { + "id": 1, + "name": "Creator 1 in Session 1", + "avatar": null, + "category": "Phones & Electronics", + "ecommerce_level": "L2", + "exposure_level": "KOC-1", + "followers": 162.2, + "gmv": 534.1, + "items_sold": 18.1, + "avg_video_views": 1.9, + "has_ecommerce": true, + "tiktok_url": null, + "session": 1 + }, + // 更多创作者... + ] + } +} +``` + +### 6. 获取创作者详情 + +- **URL**: `http://localhost:8000/api/discovery/creators/{id}/` +- **方法**: GET +- **描述**: 获取特定创作者的详细信息 +- **请求参数**: 路径参数 `id` +- **响应示例**: + +```json +{ + "code": 200, + "message": "获取创作者详情成功", + "data": { + "id": 1, + "name": "Creator 1 in Session 1", + "avatar": null, + "category": "Phones & Electronics", + "ecommerce_level": "L2", + "exposure_level": "KOC-1", + "followers": 162.2, + "gmv": 534.1, + "items_sold": 18.1, + "avg_video_views": 1.9, + "has_ecommerce": true, + "tiktok_url": null, + "session": 1 + } +} +``` + +## 在Apifox中设置环境变量 + +创建一个名为"本地开发环境"的环境,设置以下变量: +- `baseUrl`: `http://localhost:8000/api` + +这样可以在所有请求中使用`{{baseUrl}}/discovery/...`来替代完整URL。 + +## 测试流程示例 + +1. 启动Django服务器:`python manage.py runserver` +2. 在Apifox中发送请求"获取搜索会话列表" +3. 在响应中选择一个会话ID +4. 使用该ID获取会话详情 +5. 使用"搜索创作者"接口创建新的搜索会话 +6. 验证新创建的会话是否出现在会话列表中 + +## 在Apifox中导入API + +1. 在Apifox中,点击"导入"按钮 +2. 选择"导入API" +3. 将本文档中的API信息整理成集合导入 +4. 设置每个接口的请求和响应格式 +5. 设置示例响应 + +## 注意事项 + +- 所有API响应均遵循统一的格式:`code`、`message`和`data` +- 分页接口返回格式为:`{ "code": 200, "message": "获取数据成功", "data": { "count": 总数, "next": 下一页URL, "previous": 上一页URL, "results": [] } }` +- 即使发生错误,HTTP状态码始终为200,错误信息在响应体的`code`和`message`中提供 +- 接口不需要认证,可直接访问 \ No newline at end of file diff --git a/apps/discovery/__init__.py b/apps/discovery/__init__.py new file mode 100644 index 0000000..95a2fa0 --- /dev/null +++ b/apps/discovery/__init__.py @@ -0,0 +1,3 @@ +""" +Discovery app for creator discovery and search. +""" diff --git a/apps/discovery/admin.py b/apps/discovery/admin.py new file mode 100644 index 0000000..118a202 --- /dev/null +++ b/apps/discovery/admin.py @@ -0,0 +1,18 @@ +from django.contrib import admin +from .models import SearchSession, Creator + + +@admin.register(SearchSession) +class SearchSessionAdmin(admin.ModelAdmin): + list_display = ('session_number', 'creator_count', 'shoppable_creators', + 'avg_followers', 'avg_gmv', 'avg_video_views', 'date_created') + search_fields = ('session_number',) + list_filter = ('date_created',) + + +@admin.register(Creator) +class CreatorAdmin(admin.ModelAdmin): + list_display = ('name', 'category', 'ecommerce_level', 'exposure_level', + 'followers', 'gmv', 'avg_video_views', 'has_ecommerce') + search_fields = ('name', 'category') + list_filter = ('category', 'ecommerce_level', 'exposure_level', 'has_ecommerce') diff --git a/apps/discovery/apps.py b/apps/discovery/apps.py new file mode 100644 index 0000000..9da2d04 --- /dev/null +++ b/apps/discovery/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class DiscoveryConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.discovery' + verbose_name = 'Creator Discovery' diff --git a/apps/discovery/exceptions.py b/apps/discovery/exceptions.py new file mode 100644 index 0000000..be549ff --- /dev/null +++ b/apps/discovery/exceptions.py @@ -0,0 +1,31 @@ +from rest_framework.views import exception_handler +from rest_framework.response import Response + + +def custom_exception_handler(exc, context): + """ + 自定义异常处理 + """ + # 先调用REST framework默认的异常处理方法获得标准错误响应对象 + response = exception_handler(exc, context) + + # 如果response为None,说明REST framework无法处理该异常 + # 我们依然返回None,让Django处理该异常 + if response is None: + return None + + # 定制响应格式 + error_data = { + 'code': response.status_code, + 'message': str(exc), + 'data': None + } + + # 如果是验证错误,取出错误详情 + if hasattr(exc, 'detail'): + error_data['message'] = str(exc.detail) + if isinstance(exc.detail, dict): + error_data['data'] = exc.detail + + # 统一返回200状态码,将实际错误码放入响应体 + return Response(error_data, status=200) \ No newline at end of file diff --git a/apps/discovery/management/__init__.py b/apps/discovery/management/__init__.py new file mode 100644 index 0000000..43028f0 --- /dev/null +++ b/apps/discovery/management/__init__.py @@ -0,0 +1,3 @@ +""" +Discovery app management commands. +""" \ No newline at end of file diff --git a/apps/discovery/management/commands/__init__.py b/apps/discovery/management/commands/__init__.py new file mode 100644 index 0000000..43028f0 --- /dev/null +++ b/apps/discovery/management/commands/__init__.py @@ -0,0 +1,3 @@ +""" +Discovery app management commands. +""" \ No newline at end of file diff --git a/apps/discovery/management/commands/create_mock_discovery_data.py b/apps/discovery/management/commands/create_mock_discovery_data.py new file mode 100644 index 0000000..14247ba --- /dev/null +++ b/apps/discovery/management/commands/create_mock_discovery_data.py @@ -0,0 +1,73 @@ +import random +from django.core.management.base import BaseCommand +from django.utils import timezone +from apps.discovery.models import SearchSession, Creator + + +class Command(BaseCommand): + help = '创建模拟的Discovery数据' + + def handle(self, *args, **kwargs): + # 创建3个搜索会话 + sessions = [] + for i in range(1, 4): + creator_count = random.randint(20, 100) + shoppable_creators = random.randint(3, 26) + + # 创建不同的日期 + if i == 1: + date = timezone.datetime(2024, 1, 6).date() + elif i == 2: + date = timezone.datetime(2022, 1, 7).date() + else: + date = timezone.datetime(2023, 4, 27).date() + + session = SearchSession.objects.create( + session_number=i, + creator_count=creator_count, + shoppable_creators=shoppable_creators, + avg_followers=162.2, + avg_gmv=534.1, + avg_video_views=1.9, + date_created=date + ) + sessions.append(session) + self.stdout.write(self.style.SUCCESS(f'创建会话: {session}')) + + # 创建创作者数据 + categories = [ + 'Phones & Electronics', + 'Womenswear & Underwear', + 'Sports & Outdoor', + 'Food & Beverage', + 'Health', + 'Kitchenware', + 'Furniture', + 'Shoes', + 'Home Supplies', + ] + + ecommerce_levels = ['L1', 'L2', 'L3', 'L4', 'L5', 'New tag'] + exposure_levels = ['KOC-1', 'KOC-2', 'KOL-2', 'KOL-3', 'New tag'] + + for session in sessions: + # 每个会话创建随机数量的创作者 + creator_count = random.randint(10, 20) + for i in range(creator_count): + has_ecommerce = random.choice([True, False]) + + creator = Creator.objects.create( + session=session, + name=f"Creator {i+1} in Session {session.session_number}", + category=random.choice(categories), + ecommerce_level=random.choice(ecommerce_levels), + exposure_level=random.choice(exposure_levels), + followers=162.2, + gmv=534.1, + items_sold=18.1, + avg_video_views=1.9, + has_ecommerce=has_ecommerce + ) + self.stdout.write(f'创建创作者: {creator.name}') + + self.stdout.write(self.style.SUCCESS('成功创建所有模拟数据!')) \ No newline at end of file diff --git a/apps/discovery/migrations/0001_initial.py b/apps/discovery/migrations/0001_initial.py new file mode 100644 index 0000000..60e63fb --- /dev/null +++ b/apps/discovery/migrations/0001_initial.py @@ -0,0 +1,57 @@ +# Generated by Django 5.2 on 2025-05-16 02:40 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='SearchSession', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('session_number', models.IntegerField(default=1, verbose_name='会话编号')), + ('creator_count', models.IntegerField(default=0, verbose_name='创作者数量')), + ('shoppable_creators', models.IntegerField(default=0, verbose_name='可购物创作者数量')), + ('avg_followers', models.FloatField(default=0, verbose_name='平均粉丝数')), + ('avg_gmv', models.FloatField(default=0, verbose_name='平均GMV')), + ('avg_video_views', models.FloatField(default=0, verbose_name='平均视频观看量')), + ('date_created', models.DateField(default=django.utils.timezone.now, verbose_name='创建日期')), + ], + options={ + 'verbose_name': '搜索会话', + 'verbose_name_plural': '搜索会话', + 'ordering': ['-date_created'], + }, + ), + migrations.CreateModel( + name='Creator', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='创作者名称')), + ('avatar', models.URLField(blank=True, null=True, verbose_name='头像URL')), + ('category', models.CharField(choices=[('Phones & Electronics', 'Phones & Electronics'), ('Womenswear & Underwear', 'Womenswear & Underwear'), ('Sports & Outdoor', 'Sports & Outdoor'), ('Food & Beverage', 'Food & Beverage'), ('Health', 'Health'), ('Kitchenware', 'Kitchenware'), ('Furniture', 'Furniture'), ('Shoes', 'Shoes'), ('Home Supplies', 'Home Supplies')], max_length=50, verbose_name='类别')), + ('ecommerce_level', models.CharField(choices=[('L1', 'Level 1'), ('L2', 'Level 2'), ('L3', 'Level 3'), ('L4', 'Level 4'), ('L5', 'Level 5'), ('New tag', 'New Tag')], max_length=10, verbose_name='电商等级')), + ('exposure_level', models.CharField(choices=[('KOC-1', 'KOC-1'), ('KOC-2', 'KOC-2'), ('KOL-2', 'KOL-2'), ('KOL-3', 'KOL-3'), ('New tag', 'New Tag')], max_length=10, verbose_name='曝光等级')), + ('followers', models.FloatField(default=0, verbose_name='粉丝数')), + ('gmv', models.FloatField(default=0, verbose_name='GMV')), + ('items_sold', models.FloatField(default=0, verbose_name='销售项目数')), + ('avg_video_views', models.FloatField(default=0, verbose_name='平均视频观看量')), + ('has_ecommerce', models.BooleanField(default=False, verbose_name='是否有电商')), + ('tiktok_url', models.URLField(blank=True, null=True, verbose_name='抖音链接')), + ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='creators', to='discovery.searchsession', verbose_name='所属会话')), + ], + options={ + 'verbose_name': '创作者', + 'verbose_name_plural': '创作者', + 'ordering': ['name'], + }, + ), + ] diff --git a/apps/discovery/migrations/0002_alter_searchsession_date_created.py b/apps/discovery/migrations/0002_alter_searchsession_date_created.py new file mode 100644 index 0000000..5a7b7dd --- /dev/null +++ b/apps/discovery/migrations/0002_alter_searchsession_date_created.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2 on 2025-05-16 03:12 + +import apps.discovery.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('discovery', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='searchsession', + name='date_created', + field=models.DateField(default=apps.discovery.models.get_current_date, verbose_name='创建日期'), + ), + ] diff --git a/apps/discovery/migrations/__init__.py b/apps/discovery/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/discovery/models.py b/apps/discovery/models.py new file mode 100644 index 0000000..a9f6459 --- /dev/null +++ b/apps/discovery/models.py @@ -0,0 +1,79 @@ +from django.db import models +from django.utils import timezone + + +def get_current_date(): + """返回当前日期(非日期时间)""" + return timezone.now().date() + + +class SearchSession(models.Model): + """搜索历史记录""" + session_number = models.IntegerField(default=1, verbose_name="会话编号") + creator_count = models.IntegerField(default=0, verbose_name="创作者数量") + shoppable_creators = models.IntegerField(default=0, verbose_name="可购物创作者数量") + avg_followers = models.FloatField(default=0, verbose_name="平均粉丝数") + avg_gmv = models.FloatField(default=0, verbose_name="平均GMV") + avg_video_views = models.FloatField(default=0, verbose_name="平均视频观看量") + date_created = models.DateField(default=get_current_date, verbose_name="创建日期") + + class Meta: + verbose_name = "搜索会话" + verbose_name_plural = "搜索会话" + ordering = ['-date_created'] + + def __str__(self): + return f"会话 {self.session_number} - {self.date_created}" + + +class Creator(models.Model): + """创作者信息""" + ECOMMERCE_LEVELS = [ + ('L1', 'Level 1'), + ('L2', 'Level 2'), + ('L3', 'Level 3'), + ('L4', 'Level 4'), + ('L5', 'Level 5'), + ('New tag', 'New Tag'), + ] + + EXPOSURE_LEVELS = [ + ('KOC-1', 'KOC-1'), + ('KOC-2', 'KOC-2'), + ('KOL-2', 'KOL-2'), + ('KOL-3', 'KOL-3'), + ('New tag', 'New Tag'), + ] + + CATEGORIES = [ + ('Phones & Electronics', 'Phones & Electronics'), + ('Womenswear & Underwear', 'Womenswear & Underwear'), + ('Sports & Outdoor', 'Sports & Outdoor'), + ('Food & Beverage', 'Food & Beverage'), + ('Health', 'Health'), + ('Kitchenware', 'Kitchenware'), + ('Furniture', 'Furniture'), + ('Shoes', 'Shoes'), + ('Home Supplies', 'Home Supplies'), + ] + + session = models.ForeignKey(SearchSession, on_delete=models.CASCADE, related_name='creators', verbose_name="所属会话") + name = models.CharField(max_length=100, verbose_name="创作者名称") + avatar = models.URLField(blank=True, null=True, verbose_name="头像URL") + category = models.CharField(max_length=50, choices=CATEGORIES, verbose_name="类别") + ecommerce_level = models.CharField(max_length=10, choices=ECOMMERCE_LEVELS, verbose_name="电商等级") + exposure_level = models.CharField(max_length=10, choices=EXPOSURE_LEVELS, verbose_name="曝光等级") + followers = models.FloatField(default=0, verbose_name="粉丝数") + gmv = models.FloatField(default=0, verbose_name="GMV") + items_sold = models.FloatField(default=0, verbose_name="销售项目数") + avg_video_views = models.FloatField(default=0, verbose_name="平均视频观看量") + has_ecommerce = models.BooleanField(default=False, verbose_name="是否有电商") + tiktok_url = models.URLField(blank=True, null=True, verbose_name="抖音链接") + + class Meta: + verbose_name = "创作者" + verbose_name_plural = "创作者" + ordering = ['name'] + + def __str__(self): + return self.name diff --git a/apps/discovery/pagination.py b/apps/discovery/pagination.py new file mode 100644 index 0000000..508301d --- /dev/null +++ b/apps/discovery/pagination.py @@ -0,0 +1,22 @@ +from rest_framework.pagination import PageNumberPagination +from rest_framework.response import Response + + +class StandardResultsSetPagination(PageNumberPagination): + """标准分页器""" + page_size = 20 + page_size_query_param = 'page_size' + max_page_size = 100 + + def get_paginated_response(self, data): + """自定义分页响应格式""" + return Response({ + "code": 200, + "message": "获取数据成功", + "data": { + "count": self.page.paginator.count, + "next": self.get_next_link(), + "previous": self.get_previous_link(), + "results": data + } + }) \ No newline at end of file diff --git a/apps/discovery/serializers.py b/apps/discovery/serializers.py new file mode 100644 index 0000000..a178314 --- /dev/null +++ b/apps/discovery/serializers.py @@ -0,0 +1,35 @@ +from rest_framework import serializers +from .models import SearchSession, Creator + + +class CreatorSerializer(serializers.ModelSerializer): + """创作者序列化器""" + + class Meta: + model = Creator + fields = '__all__' + + +class CreatorDetailSerializer(serializers.ModelSerializer): + """创作者详细信息序列化器""" + + class Meta: + model = Creator + fields = '__all__' + + +class SearchSessionSerializer(serializers.ModelSerializer): + """搜索会话序列化器""" + + class Meta: + model = SearchSession + fields = '__all__' + + +class SearchSessionDetailSerializer(serializers.ModelSerializer): + """搜索会话详细信息序列化器,包含创作者数据""" + creators = CreatorSerializer(many=True, read_only=True) + + class Meta: + model = SearchSession + fields = '__all__' \ No newline at end of file diff --git a/apps/discovery/tests.py b/apps/discovery/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/discovery/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/discovery/urls.py b/apps/discovery/urls.py new file mode 100644 index 0000000..a5ab756 --- /dev/null +++ b/apps/discovery/urls.py @@ -0,0 +1,12 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from .views import SearchSessionViewSet, CreatorDiscoveryViewSet + +router = DefaultRouter() +router.register(r'sessions', SearchSessionViewSet) +router.register(r'creators', CreatorDiscoveryViewSet) + +urlpatterns = [ + path('', include(router.urls)), +] \ No newline at end of file diff --git a/apps/discovery/views.py b/apps/discovery/views.py new file mode 100644 index 0000000..084847d --- /dev/null +++ b/apps/discovery/views.py @@ -0,0 +1,276 @@ +from django.shortcuts import render +from django.db.models import Q +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.permissions import AllowAny +from django.utils import timezone + +from .models import SearchSession, Creator +from .serializers import ( + SearchSessionSerializer, + SearchSessionDetailSerializer, + CreatorSerializer, + CreatorDetailSerializer +) +from .pagination import StandardResultsSetPagination + + +class ApiResponse: + """API统一响应格式""" + @staticmethod + def success(data=None, message="操作成功"): + return Response({ + "code": 200, + "message": message, + "data": data + }) + + @staticmethod + def error(message="操作失败", code=400, data=None): + return Response({ + "code": code, + "message": message, + "data": data + }, status=status.HTTP_200_OK) # 始终返回200状态码,错误信息在内容中提供 + + +class SearchSessionViewSet(viewsets.ModelViewSet): + """搜索会话视图集""" + queryset = SearchSession.objects.all() + serializer_class = SearchSessionSerializer + permission_classes = [AllowAny] + pagination_class = StandardResultsSetPagination + + def get_serializer_class(self): + if self.action == 'retrieve': + return SearchSessionDetailSerializer + return SearchSessionSerializer + + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + serializer = self.get_serializer(queryset, many=True) + return ApiResponse.success(serializer.data, "获取搜索会话列表成功") + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + serializer = self.get_serializer(instance) + return ApiResponse.success(serializer.data, "获取搜索会话详情成功") + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return ApiResponse.success(serializer.data, "创建搜索会话成功") + + def update(self, request, *args, **kwargs): + partial = kwargs.pop('partial', False) + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=partial) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + return ApiResponse.success(serializer.data, "更新搜索会话成功") + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + self.perform_destroy(instance) + return ApiResponse.success(None, "删除搜索会话成功") + + @action(detail=True, methods=['get']) + def results(self, request, pk=None): + """获取指定会话的搜索结果""" + session = self.get_object() + creators = session.creators.all() + page = self.paginate_queryset(creators) + if page is not None: + serializer = CreatorSerializer(page, many=True) + return self.get_paginated_response(serializer.data) + serializer = CreatorSerializer(creators, many=True) + return ApiResponse.success(serializer.data, "获取会话创作者列表成功") + + +class CreatorDiscoveryViewSet(viewsets.ReadOnlyModelViewSet): + """创作者发现视图集""" + queryset = Creator.objects.all() + serializer_class = CreatorSerializer + permission_classes = [AllowAny] + pagination_class = StandardResultsSetPagination + + def get_serializer_class(self): + if self.action == 'retrieve': + return CreatorDetailSerializer + return CreatorSerializer + + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + serializer = self.get_serializer(queryset, many=True) + return ApiResponse.success(serializer.data, "获取创作者列表成功") + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + serializer = self.get_serializer(instance) + return ApiResponse.success(serializer.data, "获取创作者详情成功") + + @action(detail=False, methods=['post']) + def search(self, request): + """搜索创作者""" + query = request.data.get('query', '') + category = request.data.get('category', None) + ecommerce_level = request.data.get('ecommerce_level', None) + exposure_level = request.data.get('exposure_level', None) + + # 创建模拟搜索会话 + session = self._create_mock_search_session() + + # 生成模拟搜索结果 + creators = self._generate_mock_creators(session, query, category, ecommerce_level, exposure_level) + + # 返回会话详情 + serializer = SearchSessionDetailSerializer(session) + return ApiResponse.success(serializer.data, "搜索创作者成功") + + def _create_mock_search_session(self): + """创建模拟搜索会话""" + # 获取当前最大会话编号并加1 + max_session_number = SearchSession.objects.all().order_by('-session_number').first() + session_number = 1 + if max_session_number: + session_number = max_session_number.session_number + 1 + + # 创建新会话 + session = SearchSession.objects.create( + session_number=session_number, + creator_count=100, + shoppable_creators=26, + avg_followers=162.2, + avg_gmv=534.1, + avg_video_views=1.9, + date_created=timezone.now().date() # 将datetime转换为date类型 + ) + return session + + def _generate_mock_creators(self, session, query, category=None, ecommerce_level=None, exposure_level=None): + """生成模拟创作者数据""" + # 模拟数据 - 这里可以根据实际需求调整 + mock_creators = [ + { + "name": "Mock Creator 1", + "category": "Phones & Electronics", + "ecommerce_level": "L2", + "exposure_level": "KOC-1", + "followers": 162.2, + "gmv": 534.1, + "items_sold": 18.1, + "avg_video_views": 1.9, + "has_ecommerce": True + }, + { + "name": "Mock Creator 2", + "category": "Womenswear & Underwear", + "ecommerce_level": "L3", + "exposure_level": "KOL-3", + "followers": 162.2, + "gmv": 534.1, + "items_sold": 18.1, + "avg_video_views": 1.9, + "has_ecommerce": False + }, + { + "name": "Mock Creator 3", + "category": "Sports & Outdoor", + "ecommerce_level": "L4", + "exposure_level": "KOC-2", + "followers": 162.2, + "gmv": 534.1, + "items_sold": 18.1, + "avg_video_views": 1.9, + "has_ecommerce": True + }, + { + "name": "Mock Creator 4", + "category": "Food & Beverage", + "ecommerce_level": "L1", + "exposure_level": "KOC-2", + "followers": 162.2, + "gmv": 534.1, + "items_sold": 18.1, + "avg_video_views": 1.9, + "has_ecommerce": True + }, + { + "name": "Mock Creator 5", + "category": "Health", + "ecommerce_level": "L5", + "exposure_level": "KOL-2", + "followers": 162.2, + "gmv": 534.1, + "items_sold": 18.1, + "avg_video_views": 1.9, + "has_ecommerce": False + }, + { + "name": "Mock Creator 6", + "category": "Kitchenware", + "ecommerce_level": "New tag", + "exposure_level": "New tag", + "followers": 162.2, + "gmv": 534.1, + "items_sold": 18.1, + "avg_video_views": 1.9, + "has_ecommerce": True + }, + { + "name": "Mock Creator 7", + "category": "Furniture", + "ecommerce_level": "New tag", + "exposure_level": "New tag", + "followers": 162.2, + "gmv": 534.1, + "items_sold": 18.1, + "avg_video_views": 1.9, + "has_ecommerce": False + }, + { + "name": "Mock Creator 8", + "category": "Shoes", + "ecommerce_level": "New tag", + "exposure_level": "New tag", + "followers": 162.2, + "gmv": 534.1, + "items_sold": 18.1, + "avg_video_views": 1.9, + "has_ecommerce": True + }, + ] + + # 根据查询条件过滤模拟数据 + filtered_creators = mock_creators + if category: + filtered_creators = [c for c in filtered_creators if c['category'] == category] + if ecommerce_level: + filtered_creators = [c for c in filtered_creators if c['ecommerce_level'] == ecommerce_level] + if exposure_level: + filtered_creators = [c for c in filtered_creators if c['exposure_level'] == exposure_level] + + # 创建创作者记录 + for creator_data in filtered_creators: + Creator.objects.create( + session=session, + **creator_data + ) + + # 更新会话统计信息 + session.creator_count = len(filtered_creators) + session.shoppable_creators = len([c for c in filtered_creators if c['has_ecommerce']]) + session.save() + + return filtered_creators diff --git a/apps/feishu/migrations/0002_feishuauth.py b/apps/feishu/migrations/0002_feishuauth.py new file mode 100644 index 0000000..6764dc7 --- /dev/null +++ b/apps/feishu/migrations/0002_feishuauth.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2 on 2025-05-14 04:30 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('feishu', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='FeishuAuth', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('open_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='飞书Open ID')), + ('union_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='飞书Union ID')), + ('access_token', models.CharField(max_length=512, verbose_name='访问令牌')), + ('refresh_token', models.CharField(max_length=512, verbose_name='刷新令牌')), + ('expires_at', models.DateTimeField(verbose_name='令牌过期时间')), + ('is_active', models.BooleanField(default=True, verbose_name='是否有效')), + ('scopes', models.JSONField(blank=True, default=list, verbose_name='授权范围')), + ('metadata', models.JSONField(blank=True, default=dict, verbose_name='授权元数据')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feishu_auths', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': '飞书授权', + 'verbose_name_plural': '飞书授权', + 'db_table': 'feishu_auths', + }, + ), + ] diff --git a/apps/feishu/migrations/0003_feishutablemapping_delete_feishuauth.py b/apps/feishu/migrations/0003_feishutablemapping_delete_feishuauth.py new file mode 100644 index 0000000..b615c65 --- /dev/null +++ b/apps/feishu/migrations/0003_feishutablemapping_delete_feishuauth.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2 on 2025-05-14 09:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('feishu', '0002_feishuauth'), + ] + + operations = [ + migrations.CreateModel( + name='FeishuTableMapping', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('app_token', models.CharField(max_length=100, verbose_name='应用令牌')), + ('table_id', models.CharField(max_length=100, verbose_name='表格ID')), + ('table_url', models.TextField(verbose_name='表格URL')), + ('table_name', models.CharField(max_length=100, verbose_name='数据库表名')), + ('feishu_table_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='飞书表格名称')), + ('last_sync_time', models.DateTimeField(auto_now=True, verbose_name='最后同步时间')), + ('total_records', models.IntegerField(default=0, verbose_name='总记录数')), + ], + options={ + 'verbose_name': '飞书表格映射', + 'verbose_name_plural': '飞书表格映射', + 'db_table': 'feishu_table_mapping', + 'unique_together': {('app_token', 'table_id')}, + }, + ), + migrations.DeleteModel( + name='FeishuAuth', + ), + ] diff --git a/apps/feishu/models.py b/apps/feishu/models.py index 0bfe8e8..33ed820 100644 --- a/apps/feishu/models.py +++ b/apps/feishu/models.py @@ -52,4 +52,26 @@ class FeishuCreator(models.Model): class Meta: db_table = 'feishu_creators' verbose_name = '创作者数据' - verbose_name_plural = '创作者数据' \ No newline at end of file + verbose_name_plural = '创作者数据' + +class FeishuTableMapping(models.Model): + """ + 飞书多维表格与数据库表的映射关系 + """ + id = models.AutoField(primary_key=True) + app_token = models.CharField(max_length=100, verbose_name='应用令牌') + table_id = models.CharField(max_length=100, verbose_name='表格ID') + table_url = models.TextField(verbose_name='表格URL') + table_name = models.CharField(max_length=100, verbose_name='数据库表名') + feishu_table_name = models.CharField(max_length=255, blank=True, null=True, verbose_name='飞书表格名称') + last_sync_time = models.DateTimeField(auto_now=True, verbose_name='最后同步时间') + total_records = models.IntegerField(default=0, verbose_name='总记录数') + + class Meta: + db_table = 'feishu_table_mapping' + verbose_name = '飞书表格映射' + verbose_name_plural = '飞书表格映射' + unique_together = ('app_token', 'table_id') + + def __str__(self): + return f"{self.feishu_table_name or self.table_name} ({self.table_id})" \ No newline at end of file diff --git a/apps/feishu/serializers.py b/apps/feishu/serializers.py index e69de29..fbf012f 100644 --- a/apps/feishu/serializers.py +++ b/apps/feishu/serializers.py @@ -0,0 +1,13 @@ +from rest_framework import serializers + +class FeishuTableQuerySerializer(serializers.Serializer): + """飞书表格查询参数序列化器""" + app_token = serializers.CharField(required=True, help_text="飞书应用TOKEN") + table_id = serializers.CharField(required=True, help_text="表格ID") + view_id = serializers.CharField(required=False, allow_null=True, help_text="视图ID") + access_token = serializers.CharField(required=False, allow_null=True, help_text="用户访问令牌") + +class FeishuTableRecordSerializer(serializers.Serializer): + """飞书表格记录序列化器""" + record_id = serializers.CharField(read_only=True) + fields = serializers.DictField(read_only=True) diff --git a/apps/feishu/services/auto_gmail_conversation_service.py b/apps/feishu/services/auto_gmail_conversation_service.py new file mode 100644 index 0000000..d067282 --- /dev/null +++ b/apps/feishu/services/auto_gmail_conversation_service.py @@ -0,0 +1,923 @@ +import logging +import json +import time +from datetime import datetime, timedelta +import uuid +from django.db import transaction +from django.db.models import Q +from django.utils import timezone +from apps.gmail.models import GmailConversation, GmailCredential, UserGoal, AutoReplyConfig +from apps.chat.models import ChatHistory +from apps.gmail.services.gmail_service import GmailService +from apps.gmail.services.goal_service import get_conversation_summary, get_last_message, generate_recommended_reply +from apps.common.services.ai_service import AIService + +logger = logging.getLogger(__name__) + +class AutoGmailConversationService: + """ + 自动化Gmail对话服务,基于用户目标自动与多个达人进行沟通 + """ + + @staticmethod + def create_auto_conversation(user, user_email, influencer_email, greeting_message, goal_description): + """ + 创建自动对话并发送初始消息 + + Args: + user: 用户对象 + user_email: 用户邮箱(已授权) + influencer_email: 达人邮箱 + greeting_message: 初始打招呼消息 + goal_description: 对话目标 + + Returns: + tuple: (是否成功, 对话ID或错误信息, 目标对象或None) + """ + try: + # 验证用户Gmail凭证 + credential = GmailCredential.objects.filter(user=user, email=user_email).first() + if not credential: + return False, f"未找到{user_email}的Gmail授权", None + + # 检查是否已存在与该达人的对话 + existing_conversation = GmailConversation.objects.filter( + user=user, + user_email=user_email, + influencer_email=influencer_email, + is_active=True + ).first() + + conversation = None + if existing_conversation: + conversation = existing_conversation + logger.info(f"找到与达人 {influencer_email} 的现有对话: {conversation.conversation_id}") + # 打印对话的has_sent_greeting字段值,以便追踪 + logger.info(f"对话 {conversation.conversation_id} 的has_sent_greeting值: {conversation.has_sent_greeting}") + else: + # 创建新的对话 + conversation_id = f"gmail_{user.id}_{str(uuid.uuid4())[:8]}" + conversation = GmailConversation.objects.create( + user=user, + user_email=user_email, + influencer_email=influencer_email, + conversation_id=conversation_id, + title=f"与 {influencer_email} 的Gmail对话", + is_active=True, + has_sent_greeting=False, # 新创建的对话尚未发送打招呼消息 + metadata={ + 'auto_conversation': True, + 'created_at': timezone.now().isoformat() + } + ) + logger.info(f"创建新的自动对话: {conversation.conversation_id}, has_sent_greeting={conversation.has_sent_greeting}") + + # 只有当对话尚未发送打招呼消息时才发送 + logger.info(f"检查是否需要发送打招呼消息: conversation_id={conversation.conversation_id}, has_sent_greeting={conversation.has_sent_greeting}") + if not conversation.has_sent_greeting: + logger.info(f"对话 {conversation.conversation_id} 尚未发送打招呼消息,准备发送") + # 设置固定的问候消息 + greeting_message = """Paid Collaboration Opportunity with TikTok's #1 Fragrance Brand 🌸 +Hi, +I'm Vira from OOIN Media, and I'm reaching out on behalf of a top-performing fragrance brand Sttes on TikTok Shop—currently ranked #1 in the perfume category. +This brand has already launched several viral products and is now looking to partner with select creators like you through paid collaborations to continue driving awareness and sales. +We'd love to explore a partnership and would appreciate it if you could share: +Your rate for a single TikTok video +Whether you offer bundle pricing for multiple videos +Any additional details or formats you offer (e.g. story integration, livestream add-ons, etc.) +The product has strong market traction, proven conversions, and a competitive commission structure if you're also open to affiliate partnerships. +Looking forward to the opportunity to work together and hearing your rates! +Warm regards, +Vira +OOIN Media""" + + # 发送打招呼消息 + subject = "Paid Collaboration Opportunity with TikTok's #1 Fragrance Brand" + logger.info(f"开始向 {influencer_email} 发送打招呼消息") + success, message_id = GmailService.send_email( + user=user, + user_email=user_email, + to_email=influencer_email, + subject=subject, + body=greeting_message + ) + + if not success: + logger.error(f"发送打招呼消息失败: {message_id}") + return False, f"发送打招呼消息失败: {message_id}", None + + # 更新对话的has_sent_greeting字段 + conversation.has_sent_greeting = True + conversation.save(update_fields=['has_sent_greeting', 'updated_at']) + logger.info(f"对话 {conversation.conversation_id} 已发送打招呼消息,并更新了has_sent_greeting={conversation.has_sent_greeting}") + else: + logger.info(f"对话 {conversation.conversation_id} 已经发送过打招呼消息,不再重复发送") + + # 创建目标 + goal, error = AutoGmailConversationService.create_user_goal( + user=user, + conversation_id=conversation.conversation_id, + goal_description=goal_description + ) + + if error: + return False, f"创建对话目标失败: {error}", None + + # 设置或刷新Gmail推送通知 + notification_result, notification_error = GmailService.setup_gmail_push_notification( + user=user, + user_email=user_email + ) + + if not notification_result and notification_error: + logger.warning(f"设置Gmail推送通知失败: {notification_error},但对话创建成功") + + # 查询最新的conversation对象以验证字段值 + refreshed_conversation = GmailConversation.objects.get(id=conversation.id) + logger.info(f"返回结果前检查对话状态: conversation_id={refreshed_conversation.conversation_id}, has_sent_greeting={refreshed_conversation.has_sent_greeting}") + + return True, conversation.conversation_id, goal + + except Exception as e: + logger.error(f"创建自动对话失败: {str(e)}") + import traceback + logger.error(traceback.format_exc()) + return False, f"创建自动对话失败: {str(e)}", None + + @staticmethod + def create_user_goal(user, conversation_id, goal_description): + """ + 为用户创建对话目标 + + Args: + user: 用户对象 + conversation_id: 对话ID + goal_description: 目标描述 + + Returns: + tuple: (目标对象, 错误信息) + """ + try: + # 检查对话是否存在 + conversation = GmailConversation.objects.filter(conversation_id=conversation_id).first() + if not conversation: + return None, "对话不存在" + + # 检查权限 + if conversation.user.id != user.id: + return None, "无权限访问此对话" + + # 停用该用户针对这个对话的之前的目标 + UserGoal.objects.filter( + user=user, + conversation=conversation, + is_active=True + ).update(is_active=False) + + # 创建新目标 + goal = UserGoal.objects.create( + user=user, + conversation=conversation, + description=goal_description, + is_active=True, + status='pending', + metadata={ + 'created_at': timezone.now().isoformat(), + 'influencer_email': conversation.influencer_email, + 'user_email': conversation.user_email + } + ) + + logger.info(f"用户 {user.username} 为对话 {conversation_id} 创建了新目标") + return goal, None + + except Exception as e: + logger.error(f"创建用户目标失败: {str(e)}") + return None, f"创建用户目标失败: {str(e)}" + + @staticmethod + def check_goal_achieved(goal_description, conversation_history): + """ + 检查目标是否已经达成 + + Args: + goal_description: 目标描述 + conversation_history: 对话历史 + + Returns: + tuple: (是否达成, 置信度, 错误信息) + """ + try: + # 格式化对话历史 + dialog_text = "\n\n".join([ + f"{'用户' if msg.get('role') == 'user' else '达人'}: {msg.get('content', '')}" + for msg in conversation_history + ]) + + # 构建提示词 + prompt = f""" +分析以下对话历史,判断用户的目标是否已经达成: + +用户目标: {goal_description} + +对话历史: +{dialog_text} + +请分析并判断此目标是否已经达成。仅回答"是"或"否",以及一个0到1之间的数字表示达成目标的置信度,格式为: "判断结果|置信度"。 +例如: "是|0.85" 或 "否|0.32" +""" + + messages = [ + { + "role": "system", + "content": "你是一个专业的目标达成分析师,你的任务是判断对话中用户的目标是否已经达成。" + }, + { + "role": "user", + "content": prompt + } + ] + + # 调用AI服务 + result, error = AIService.call_silicon_cloud_api( + messages, + model="Pro/deepseek-ai/DeepSeek-R1", + max_tokens=100, + temperature=0.1 + ) + + if error: + return False, 0, error + + # 解析结果 + result = result.strip().lower() + + # 尝试提取判断结果和置信度 + if '|' in result: + judgment, confidence_str = result.split('|', 1) + is_achieved = judgment.strip() == '是' + + try: + confidence = float(confidence_str.strip()) + confidence = max(0, min(1, confidence)) # 确保在0-1之间 + except ValueError: + confidence = 0.5 # 默认置信度 + + return is_achieved, confidence, None + else: + # 兜底处理 + is_achieved = '是' in result + return is_achieved, 0.5, "AI返回格式异常" + + except Exception as e: + logger.error(f"检查目标达成状态失败: {str(e)}") + return False, 0, f"检查目标达成状态失败: {str(e)}" + + @staticmethod + def generate_reply_and_send(user, goal_id, conversation_id=None): + """ + 生成回复并发送邮件 + + Args: + user: 用户对象 + goal_id: 目标ID + conversation_id: 对话ID (可选,如果提供则验证目标与对话的关联) + + Returns: + tuple: (成功标志, 错误信息) + """ + try: + # 获取目标信息 + goal = UserGoal.objects.filter(id=goal_id, user=user).first() + if not goal: + return False, "目标不存在或不属于当前用户" + + # 获取关联的对话ID + goal_conversation_id = goal.get_conversation_id() + + # 如果提供了conversation_id,验证与目标关联的对话一致 + if conversation_id and goal_conversation_id and conversation_id != goal_conversation_id: + return False, "目标与对话不匹配" + + # 确定最终使用的对话ID + final_conversation_id = conversation_id or goal_conversation_id + if not final_conversation_id: + return False, "无法确定对话ID,目标未关联对话" + + # 获取对话信息 + conversation = GmailConversation.objects.filter(conversation_id=final_conversation_id).first() + if not conversation: + return False, "对话不存在" + + # 检查权限 + if conversation.user.id != user.id: + return False, "无权限访问此对话" + + # 获取Gmail凭证 + credential = GmailCredential.objects.filter(user=user, email=conversation.user_email).first() + if not credential: + return False, f"未找到{conversation.user_email}的Gmail凭证" + + # 获取对话摘要 + conversation_summary = get_conversation_summary(final_conversation_id) + if not conversation_summary: + conversation_summary = "无对话摘要" + + # 获取最后一条达人消息 + last_message = get_last_message(final_conversation_id) + if not last_message: + return False, "对话中没有达人消息,无法生成回复" + + # 生成回复内容 + reply_content, error = generate_recommended_reply( + user=user, + goal_description=goal.description, + conversation_summary=conversation_summary, + last_message=last_message + ) + + if error: + return False, f"生成回复内容失败: {error}" + + if not reply_content: + return False, "生成的回复内容为空" + + # 从最后一条达人消息中提取主题 + subject = "回复: " + if last_message and "主题:" in last_message: + subject_line = last_message.split("\n")[0] + if "主题:" in subject_line: + original_subject = subject_line.split("主题:", 1)[1].strip() + subject = f"回复: {original_subject}" + + # 发送邮件 + success, message_id = GmailService.send_email( + user=user, + user_email=conversation.user_email, + to_email=conversation.influencer_email, + subject=subject, + body=reply_content + ) + + if not success: + return False, f"发送邮件失败: {message_id}" + + # 更新目标状态 + goal.last_activity_time = timezone.now() + goal.status = 'in_progress' + goal.metadata = goal.metadata or {} + goal.metadata['last_reply_time'] = timezone.now().isoformat() + goal.metadata['last_reply_id'] = message_id + goal.save() + + logger.info(f"成功为用户 {user.username} 生成并发送回复邮件") + return True, message_id + + except Exception as e: + logger.error(f"生成回复并发送邮件失败: {str(e)}") + return False, f"生成回复并发送邮件失败: {str(e)}" + + @staticmethod + def get_active_goals_for_conversation(user, conversation_id): + """ + 获取指定对话的活跃目标 + + Args: + user: 用户对象 + conversation_id: 对话ID + + Returns: + UserGoal: 活跃目标对象或None + """ + # 先查找对话对象 + conversation = GmailConversation.objects.filter(conversation_id=conversation_id).first() + if not conversation: + return None + + # 查找与对话关联的活跃目标 + return UserGoal.objects.filter( + user=user, + conversation=conversation, + is_active=True + ).order_by('-created_at').first() + + @staticmethod + def get_recommended_reply(user, conversation_id): + """ + 获取推荐回复 + + Args: + user: 用户对象 + conversation_id: 对话ID + + Returns: + tuple: (推荐回复内容, 错误信息) + """ + try: + # 获取对话信息 + conversation = GmailConversation.objects.filter(conversation_id=conversation_id).first() + if not conversation: + return None, "对话不存在" + + # 检查权限 + if conversation.user.id != user.id: + return None, "无权限访问此对话" + + # 获取该对话的目标 + goal = AutoGmailConversationService.get_active_goals_for_conversation(user, conversation_id) + if not goal: + return None, "未找到该对话的活跃目标" + + # 获取对话摘要 + conversation_summary = get_conversation_summary(conversation_id) + if not conversation_summary: + conversation_summary = "无对话摘要" + + # 获取最后一条达人消息 + last_message = get_last_message(conversation_id) + if not last_message: + return None, "对话中没有达人消息,无法生成推荐回复" + + # 生成推荐回复 + reply_content, error = generate_recommended_reply( + user=user, + goal_description=goal.description, + conversation_summary=conversation_summary, + last_message=last_message + ) + + if error: + return None, f"生成推荐回复失败: {error}" + + return reply_content, None + + except Exception as e: + logger.error(f"获取推荐回复失败: {str(e)}") + return None, f"获取推荐回复失败: {str(e)}" + + @staticmethod + @transaction.atomic + def process_active_goals(): + """ + 处理所有活跃的目标,检查新消息并自动回复 + """ + try: + # 获取所有活跃目标 + active_goals = UserGoal.objects.filter( + is_active=True, + status__in=['pending', 'in_progress'] + ).select_related('user', 'conversation') + + logger.info(f"发现 {active_goals.count()} 个活跃目标需要处理") + + for goal in active_goals: + try: + # 获取对话ID + if not goal.conversation: + logger.error(f"目标 {goal.id} 没有关联对话") + continue + + conversation_id = goal.conversation.conversation_id + + # 获取用户 + user = goal.user + + # 获取最近的消息记录 - 修改查询方式避免"Cannot filter a query once a slice has been taken"错误 + # 分别获取最新的用户消息和助手消息 + latest_assistant_msg = ChatHistory.objects.filter( + conversation_id=conversation_id, + role='assistant' + ).order_by('-created_at').first() + + latest_user_msg = ChatHistory.objects.filter( + conversation_id=conversation_id, + role='user' + ).order_by('-created_at').first() + + # 获取用于分析目标达成情况的最近消息记录 + recent_messages = ChatHistory.objects.filter( + conversation_id=conversation_id + ).order_by('-created_at')[:20] + + # 生成对话历史,用于判断目标是否达成 + conversation_history = [] + for msg in reversed(list(recent_messages)): # 按时间顺序排列 + conversation_history.append({ + 'role': msg.role, + 'content': msg.content, + 'created_at': msg.created_at.isoformat() + }) + + # 检查目标是否已达成 + is_achieved, confidence, error = AutoGmailConversationService.check_goal_achieved( + goal_description=goal.description, + conversation_history=conversation_history + ) + + # 更新目标状态 + goal.metadata = goal.metadata or {} + goal.metadata['last_check_time'] = timezone.now().isoformat() + + if is_achieved and confidence >= 0.7: # 高置信度认为目标已达成 + goal.status = 'completed' + goal.completion_time = timezone.now() + goal.metadata['completion_confidence'] = confidence + goal.save() + logger.info(f"目标 {goal.id} 已达成,置信度: {confidence}") + continue + + # 获取最近的一条消息,不区分角色 + latest_msg = ChatHistory.objects.filter( + conversation_id=conversation_id + ).order_by('-created_at').first() + + # 检查是否有新的达人消息需要回复 + # 输出详细的消息角色和ID信息以便调试 + if latest_assistant_msg: + logger.info(f"最新达人消息: ID={latest_assistant_msg.id}, 时间={latest_assistant_msg.created_at}, 内容前20字符={latest_assistant_msg.content[:20]}") + if latest_user_msg: + logger.info(f"最新用户消息: ID={latest_user_msg.id}, 时间={latest_user_msg.created_at}, 内容前20字符={latest_user_msg.content[:20]}") + if latest_msg: + logger.info(f"最新消息(任意角色): ID={latest_msg.id}, 角色={latest_msg.role}, 时间={latest_msg.created_at}") + + # 如果最后一条消息是达人发的,则需要回复 + needs_reply = latest_msg and latest_msg.role == 'assistant' + + if not needs_reply: + # 更新目标状态 + goal.status = 'in_progress' + goal.save() + logger.info(f"目标 {goal.id} 无需回复,最后消息角色为: {latest_msg.role if latest_msg else '无消息'}") + continue + + # 需要回复,直接生成回复并发送 + logger.info(f"目标 {goal.id} 需要回复,最后消息来自达人") + success, msg = AutoGmailConversationService.generate_reply_and_send( + user=user, + goal_id=goal.id, + conversation_id=conversation_id + ) + + if success: + # 更新目标状态 + goal.status = 'in_progress' + goal.save() + logger.info(f"目标 {goal.id} 自动回复成功") + else: + logger.error(f"目标 {goal.id} 自动回复失败: {msg}") + + except Exception as e: + logger.error(f"处理目标 {goal.id} 时出错: {str(e)}") + + return True + + except Exception as e: + logger.error(f"处理活跃目标失败: {str(e)}") + return False + + @staticmethod + def get_all_user_conversations_with_goals(user): + """ + 获取用户所有对话和对应的目标 + + Args: + user: 用户对象 + + Returns: + list: 对话和目标信息列表 + """ + try: + # 获取用户所有对话 + conversations = GmailConversation.objects.filter(user=user).order_by('-updated_at') + + result = [] + for conversation in conversations: + # 查询该对话的活跃目标 + goal = UserGoal.objects.filter( + user=user, + conversation=conversation, + is_active=True + ).order_by('-created_at').first() + + # 准备对话和目标信息 + conv_data = { + 'conversation_id': conversation.conversation_id, + 'influencer_email': conversation.influencer_email, + 'user_email': conversation.user_email, + 'title': conversation.title, + 'last_message_time': conversation.updated_at, + 'has_active_goal': goal is not None + } + + # 如果有活跃目标,添加目标信息 + if goal: + conv_data['goal'] = { + 'id': str(goal.id), + 'description': goal.description, + 'status': goal.status, + 'created_at': goal.created_at, + 'updated_at': goal.updated_at + } + + result.append(conv_data) + + return result + + except Exception as e: + logger.error(f"获取用户对话和目标失败: {str(e)}") + return [] + + @staticmethod + def find_auto_reply_config(user_email, influencer_email): + """ + 查找匹配的自动回复配置 + + Args: + user_email: 用户Gmail邮箱 + influencer_email: 达人Gmail邮箱 + + Returns: + AutoReplyConfig: 匹配的自动回复配置或None + """ + try: + config = AutoReplyConfig.objects.filter( + user_email=user_email, + influencer_email=influencer_email, + is_enabled=True + ).first() + + return config + except Exception as e: + logger.error(f"查找自动回复配置失败: {str(e)}") + return None + + @staticmethod + def get_or_create_conversation(user, user_email, influencer_email): + """ + 获取或创建Gmail对话 + + Args: + user: 用户对象 + user_email: 用户Gmail邮箱 + influencer_email: 达人Gmail邮箱 + + Returns: + tuple: (GmailConversation, bool) - 对话对象和是否新创建 + """ + try: + # 查找现有对话 + conversation = GmailConversation.objects.filter( + user=user, + user_email=user_email, + influencer_email=influencer_email, + is_active=True + ).first() + + if conversation: + return conversation, False + + # 创建新对话 + conversation_id = f"feishu_gmail_{user.id}_{influencer_email.split('@')[0]}_{timezone.now().strftime('%m%d%H%M')}" + conversation = GmailConversation.objects.create( + user=user, + user_email=user_email, + influencer_email=influencer_email, + conversation_id=conversation_id, + title=f"飞书与 {influencer_email} 的自动对话", + is_active=True, + metadata={ + 'auto_reply': True, + 'feishu_auto': True, + 'created_at': timezone.now().isoformat() + } + ) + + return conversation, True + + except Exception as e: + logger.error(f"获取或创建Gmail对话失败: {str(e)}") + return None, False + + @staticmethod + def should_auto_reply(config, message_data): + """ + 判断是否应该自动回复 + + Args: + config: 自动回复配置 + message_data: 消息数据,包含发件人、收件人等信息 + + Returns: + bool: 是否应该自动回复 + """ + if not config or not config.can_reply(): + return False + + # 判断消息发送时间,避免回复太老的消息 + message_time = message_data.get('timestamp') + if message_time: + try: + message_time = timezone.datetime.fromisoformat(message_time.replace('Z', '+00:00')) + current_time = timezone.now() + # 只回复24小时内的消息 + if (current_time - message_time) > timedelta(hours=24): + logger.info(f"消息太旧,不自动回复: {message_time}") + return False + except Exception as e: + logger.warning(f"解析消息时间失败: {str(e)}") + + # 检查上次回复时间,避免频繁回复 + if config.last_reply_time: + time_since_last_reply = timezone.now() - config.last_reply_time + # 至少间隔30分钟 + if time_since_last_reply < timedelta(minutes=30): + logger.info(f"距离上次回复时间太短,不自动回复: {time_since_last_reply}") + return False + + return True + + + @staticmethod + def process_webhook_message(message_data): + """ + 处理来自webhook的消息推送,自动回复活跃对话 + + Args: + message_data: 邮件数据 + + Returns: + bool: 是否成功处理 + """ + try: + # 提取发件人邮箱 + from_email = message_data.get('from', {}).get('emailAddress', {}).get('address') + if not from_email: + logger.warning(f"无法提取发件人邮箱: {message_data}") + return False + + # 提取收件人邮箱 + to_emails = [] + for recipient in message_data.get('toRecipients', []): + email = recipient.get('emailAddress', {}).get('address') + if email: + to_emails.append(email) + + if not to_emails: + logger.warning(f"无法提取收件人邮箱: {message_data}") + return False + + # 收件人邮箱是我们系统用户的邮箱,发件人是达人邮箱 + user_email = to_emails[0] # 假设第一个收件人是我们系统的用户 + influencer_email = from_email + + logger.info(f"处理webhook消息: 发件人(达人)={influencer_email}, 收件人(用户)={user_email}") + + # 查找符合条件的活跃对话 + active_conversations = GmailConversation.objects.filter( + user_email=user_email, + influencer_email=influencer_email, + is_active=True + ).select_related('user') + + if not active_conversations.exists(): + logger.info(f"未找到与达人 {influencer_email} 的活跃对话") + return False + + # 获取最近的一个活跃对话 + conversation = active_conversations.order_by('-updated_at').first() + user = conversation.user + + logger.info(f"找到活跃对话: {conversation.conversation_id}, 用户: {user.username}") + + # 验证Gmail凭证 + credential = GmailCredential.objects.filter(user=user, email=user_email).first() + if not credential: + logger.error(f"未找到用户 {user.username} 的Gmail凭证: {user_email}") + return False + + # 记录邮件内容到对话历史 + message_id = message_data.get('id') + subject = message_data.get('subject', '无主题') + body = message_data.get('body', {}).get('content', '') + + # 创建达人消息记录 + chat_message = ChatHistory.objects.create( + conversation_id=conversation.conversation_id, + message_id=f"email_{message_id}", + role='assistant', # 达人消息用assistant角色 + content=f"主题: {subject}\n\n{body}", + metadata={ + 'email_id': message_id, + 'from': influencer_email, + 'to': user_email, + 'subject': subject, + 'timestamp': message_data.get('timestamp'), + 'webhook_auto': True + } + ) + + # 获取活跃目标 + goal = AutoGmailConversationService.get_active_goals_for_conversation(user, conversation.conversation_id) + + # 获取对话摘要 + conversation_summary = get_conversation_summary(conversation.conversation_id) + if not conversation_summary: + conversation_summary = "无对话摘要" + + # 获取最后一条消息 + last_message = get_last_message(conversation.conversation_id) + if not last_message: + last_message = f"主题: {subject}\n\n{body}" + + # 生成推荐回复 + goal_description = goal.description if goal else "礼貌回应邮件并获取更多信息" + reply_content, error = generate_recommended_reply( + user=user, + goal_description=goal_description, + conversation_summary=conversation_summary, + last_message=last_message + ) + + if error: + logger.error(f"生成推荐回复失败: {error}") + return False + + if not reply_content: + logger.error("生成的推荐回复内容为空") + return False + + # 构建回复的主题 + reply_subject = f"回复: {subject}" if not subject.startswith('回复:') else subject + + # 发送回复邮件 + success, reply_message_id = GmailService.send_email( + user=user, + user_email=user_email, + to_email=influencer_email, + subject=reply_subject, + body=reply_content + ) + + if not success: + logger.error(f"发送自动回复邮件失败: {reply_message_id}") + return False + + # 记录自动回复到对话历史 + ChatHistory.objects.create( + conversation_id=conversation.conversation_id, + message_id=f"email_reply_{reply_message_id}", + role='user', # 用户发出的消息用user角色 + content=reply_content, + metadata={ + 'email_id': reply_message_id, + 'from': user_email, + 'to': influencer_email, + 'subject': reply_subject, + 'auto_reply': True, + 'webhook_auto': True, + 'timestamp': timezone.now().isoformat() + } + ) + + # 更新目标状态(如果有) + if goal: + goal.last_activity_time = timezone.now() + goal.status = 'in_progress' + goal.metadata = goal.metadata or {} + goal.metadata['last_reply_time'] = timezone.now().isoformat() + goal.metadata['last_reply_id'] = reply_message_id + goal.save() + + # 检查目标是否达成 + latest_messages = ChatHistory.objects.filter( + conversation_id=conversation.conversation_id + ).order_by('-created_at')[:20] + + conversation_history = [] + for msg in reversed(list(latest_messages)): + conversation_history.append({ + 'role': msg.role, + 'content': msg.content, + 'created_at': msg.created_at.isoformat() + }) + + is_achieved, confidence, error = AutoGmailConversationService.check_goal_achieved( + goal_description=goal.description, + conversation_history=conversation_history + ) + + if is_achieved and confidence >= 0.7: + goal.status = 'completed' + goal.completion_time = timezone.now() + goal.metadata['completion_confidence'] = confidence + goal.save() + logger.info(f"目标 {goal.id} 已达成,置信度: {confidence}") + + logger.info(f"成功回复webhook消息: {user_email} -> {influencer_email}") + return True + + except Exception as e: + logger.error(f"处理webhook消息失败: {str(e)}") + import traceback + logger.error(traceback.format_exc()) + return False \ No newline at end of file diff --git a/apps/feishu/services/bitable_service.py b/apps/feishu/services/bitable_service.py new file mode 100644 index 0000000..e08b8b7 --- /dev/null +++ b/apps/feishu/services/bitable_service.py @@ -0,0 +1,216 @@ +import json +import re +import requests +from urllib.parse import urljoin + +# 基础URL地址 +BASE_API_URL = "https://open.feishu.cn/open-apis/" + + +class BitableService: + """ + 飞书多维表格服务类 + """ + + @staticmethod + def make_request(method, url, headers=None, params=None, json_data=None): + """ + 发送请求到飞书API + + Args: + method: 请求方法,GET/POST等 + url: API路径,不含基础URL + headers: 请求头 + params: URL参数 + json_data: JSON数据体 + + Returns: + dict: 响应数据 + """ + full_url = urljoin(BASE_API_URL, url) + + if headers is None: + headers = {} + + response = requests.request( + method=method, + url=full_url, + headers=headers, + params=params, + json=json_data + ) + + # 检查响应 + if not response.ok: + error_msg = f"API 请求失败: {response.status_code}, 响应: {response.text}" + print(error_msg) + raise Exception(error_msg) + + return response.json() + + @staticmethod + def extract_params_from_url(table_url): + """ + 从URL中提取app_token和table_id + + Args: + table_url: 飞书多维表格URL + + Returns: + tuple: (app_token, table_id) 元组 + + Raises: + ValueError: 如果无法从URL中提取必要参数 + """ + app_token_match = re.search(r'base/([^?]+)', table_url) + table_id_match = re.search(r'table=([^&]+)', table_url) + + if not app_token_match or not table_id_match: + raise ValueError("无法从URL中提取必要参数,请确认URL格式正确") + + return app_token_match.group(1), table_id_match.group(1) + + @staticmethod + def get_metadata(app_token, table_id, access_token): + """ + 获取多维表格元数据 + + Args: + app_token: 应用令牌 + table_id: 表格ID + access_token: 访问令牌 + + Returns: + dict: 表格元数据 + """ + try: + # 构造请求 + url = f"bitable/v1/apps/{app_token}/tables/{table_id}" + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json" + } + + # 发送请求 + response = BitableService.make_request("GET", url, headers=headers) + + # 检查响应 + if response and "code" in response and response["code"] == 0: + return response.get("data", {}).get("table", {}) + + # 发生错误 + error_msg = f"获取多维表格元数据失败: {json.dumps(response)}" + print(error_msg) + raise Exception(error_msg) + + except Exception as e: + # 如果正常API调用失败,使用替代方法 + print(f"获取多维表格元数据失败: {str(e)}") + # 简单返回一个基本名称 + return { + "name": f"table_{table_id}", + "description": "自动创建的表格" + } + + @staticmethod + def search_records(app_token, table_id, access_token, filter_exp=None, sort=None, page_size=20, page_token=None): + """ + 查询多维表格记录 + + Args: + app_token: 应用令牌 + table_id: 表格ID + access_token: 访问令牌 + filter_exp: 过滤条件 + sort: 排序条件 + page_size: 每页大小 + page_token: 分页标记 + + Returns: + dict: 查询结果 + + Raises: + Exception: 查询失败时抛出异常 + """ + try: + # 构造请求URL + url = f"bitable/v1/apps/{app_token}/tables/{table_id}/records/search" + + # 请求头 + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json" + } + + # 查询参数 + params = {"page_size": page_size} + if page_token: + params["page_token"] = page_token + + # 请求体 + json_data = {} + if filter_exp: + json_data["filter"] = filter_exp + if sort: + json_data["sort"] = sort + + # 发送请求 + response = BitableService.make_request("POST", url, headers=headers, params=params, json_data=json_data) + + # 检查响应 + if response and "code" in response and response["code"] == 0: + return response.get("data", {}) + + # 发生错误 + error_msg = f"查询飞书多维表格失败: {json.dumps(response)}" + print(error_msg) + raise Exception(error_msg) + + except Exception as e: + # 记录详细错误 + print(f"查询飞书多维表格发生错误: {str(e)}") + raise + + @staticmethod + def get_table_fields(app_token, table_id, access_token): + """ + 获取多维表格的字段信息 + + Args: + app_token: 应用令牌 + table_id: 表格ID + access_token: 访问令牌 + + Returns: + list: 字段信息列表 + """ + try: + # 构造请求 + url = f"bitable/v1/apps/{app_token}/tables/{table_id}/fields" + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json" + } + params = {"page_size": 100} + + # 发送请求 + response = BitableService.make_request("GET", url, headers=headers, params=params) + + # 检查响应 + if response and "code" in response and response["code"] == 0: + return response.get("data", {}).get("items", []) + + # 发生错误 + error_msg = f"获取多维表格字段失败: {json.dumps(response)}" + print(error_msg) + raise Exception(error_msg) + + except Exception as e: + # 记录详细错误 + print(f"获取字段信息失败: {str(e)}") + # 如果获取失败,返回一个基本字段集 + return [ + {"field_name": "title", "type": "text", "property": {}}, + {"field_name": "description", "type": "text", "property": {}}, + {"field_name": "created_time", "type": "datetime", "property": {}} + ] \ No newline at end of file diff --git a/apps/feishu/services/data_sync_service.py b/apps/feishu/services/data_sync_service.py new file mode 100644 index 0000000..fa9f5f8 --- /dev/null +++ b/apps/feishu/services/data_sync_service.py @@ -0,0 +1,346 @@ +import logging +import json +from django.db import connection, models, transaction +from django.apps import apps +from django.db.models.fields import CharField, TextField, EmailField, IntegerField, FloatField +from django.db.models.fields.json import JSONField +from django.db.utils import ProgrammingError, DataError +# 不再需要飞书 SDK +# from lark_oapi.api.bitable.v1 import * +# from lark_oapi.api.bitable.v1.model.app_table_field import ListAppTableFieldRequest, ListAppTableFieldResponse +from ..models import FeishuTableMapping +from .bitable_service import BitableService + +logger = logging.getLogger(__name__) + + +class DataSyncService: + """ + 数据同步服务 + 将飞书多维表格数据同步到数据库中 + """ + + @staticmethod + def get_or_create_table_mapping(app_token, table_id, table_url, feishu_table_name=None, table_name=None): + """ + 获取或创建表格映射 + + Args: + app_token: 应用令牌 + table_id: 表格ID + table_url: 表格URL + feishu_table_name: 飞书表格名称 + table_name: 自定义表名 + + Returns: + mapping: 表格映射对象 + created: 是否为新创建 + """ + try: + # 尝试查找现有映射 + mapping = FeishuTableMapping.objects.get(app_token=app_token, table_id=table_id) + created = False + + # 如果提供了表名,更新映射关系 + if table_name and mapping.table_name != table_name: + mapping.table_name = table_name + mapping.save(update_fields=['table_name']) + + # 如果提供了飞书表格名称,更新映射关系 + if feishu_table_name and mapping.feishu_table_name != feishu_table_name: + mapping.feishu_table_name = feishu_table_name + mapping.save(update_fields=['feishu_table_name']) + + # 确保URL是最新的 + if mapping.table_url != table_url: + mapping.table_url = table_url + mapping.save(update_fields=['table_url']) + + return mapping, created + + except FeishuTableMapping.DoesNotExist: + # 创建新映射 + # 如果没有提供自定义表名,使用默认命名规则 + if not table_name: + base_name = feishu_table_name or f"table_{table_id}" + table_name = f"feishu_{base_name.lower().replace(' ', '_').replace('-', '_')}" + + # 处理特殊字符,确保表名合法 + import re + table_name = re.sub(r'[^a-zA-Z0-9_]', '', table_name) + + # 确保表名不会太长 + if len(table_name) > 50: + table_name = table_name[:45] + "_" + table_id[-4:] + + # 创建新映射 + mapping = FeishuTableMapping.objects.create( + app_token=app_token, + table_id=table_id, + table_url=table_url, + table_name=table_name, + feishu_table_name=feishu_table_name + ) + return mapping, True + + @staticmethod + def create_model_from_fields(table_name, fields, app_label='feishu'): + """ + 动态创建Django模型 + + Args: + table_name: 数据库表名 + fields: 字段信息列表 + app_label: 应用标签 + + Returns: + model: 创建的模型类 + """ + # 检查模型是否已存在 + model_name = ''.join(word.capitalize() for word in table_name.split('_')) + try: + return apps.get_model(app_label, model_name) + except LookupError: + pass # 模型不存在,继续创建 + + # 定义属性字典 + attrs = { + '__module__': f'{app_label}.models', + 'Meta': type('Meta', (), { + 'db_table': table_name, + 'app_label': app_label, + 'verbose_name': table_name, + 'verbose_name_plural': table_name, + }), + 'feishu_record_id': models.CharField(max_length=255, unique=True, verbose_name='飞书记录ID'), + 'created_at': models.DateTimeField(auto_now_add=True, verbose_name='创建时间'), + 'updated_at': models.DateTimeField(auto_now=True, verbose_name='更新时间'), + } + + # 添加字段 + for field in fields: + field_name = field.get('field_name', '') + if not field_name or field_name in ['id', 'feishu_record_id', 'created_at', 'updated_at']: + continue + + # 将字段名转换为Python合法标识符 + field_name = field_name.lower().replace(' ', '_').replace('-', '_') + + # 根据字段类型创建对应的Django字段 + field_type = field.get('type', 'text') + + if field_type in ['text', 'single_select', 'multi_select']: + attrs[field_name] = models.TextField(blank=True, null=True, verbose_name=field.get('field_name', '')) + elif field_type == 'number': + attrs[field_name] = models.FloatField(blank=True, null=True, verbose_name=field.get('field_name', '')) + elif field_type == 'datetime': + attrs[field_name] = models.DateTimeField(blank=True, null=True, verbose_name=field.get('field_name', '')) + elif field_type == 'checkbox': + attrs[field_name] = models.BooleanField(default=False, verbose_name=field.get('field_name', '')) + elif field_type in ['attachment', 'person']: + attrs[field_name] = models.JSONField(default=list, blank=True, null=True, verbose_name=field.get('field_name', '')) + else: + # 默认使用文本字段 + attrs[field_name] = models.TextField(blank=True, null=True, verbose_name=field.get('field_name', '')) + + # 添加模型基类 + attrs['id'] = models.AutoField(primary_key=True) + + # 创建模型类 + model = type(model_name, (models.Model,), attrs) + + return model + + @staticmethod + def create_table_from_model(model): + """ + 根据模型创建数据库表 + + Args: + model: 模型类 + + Returns: + bool: 是否成功创建表 + """ + try: + with connection.schema_editor() as schema_editor: + schema_editor.create_model(model) + return True + except Exception as e: + logger.error(f"创建表失败: {str(e)}") + return False + + @staticmethod + def check_table_exists(table_name): + """ + 检查表是否存在 + + Args: + table_name: 表名 + + Returns: + bool: 表是否存在 + """ + with connection.cursor() as cursor: + tables = connection.introspection.table_names(cursor) + return table_name in tables + + @staticmethod + def sync_data_to_db(table_url, access_token, table_name=None, primary_key=None, auto_sync=False): + """ + 将飞书多维表格数据同步到数据库 + + Args: + table_url: 多维表格URL + access_token: 访问令牌 + table_name: 自定义表名,默认使用飞书表格名 + primary_key: 主键字段名,用于更新数据 + auto_sync: 是否自动同步(使用已保存的映射) + + Returns: + dict: 同步结果 + """ + try: + # 提取参数 + app_token, table_id = BitableService.extract_params_from_url(table_url) + + # 1. 获取表格元数据 + metadata = BitableService.get_metadata(app_token, table_id, access_token) + feishu_table_name = metadata.get('name', f'table_{table_id}') + + # 2. 获取或创建表格映射 + if auto_sync: + # 如果是自动同步模式,尝试从数据库中获取已保存的表名 + try: + mapping = FeishuTableMapping.objects.get(app_token=app_token, table_id=table_id) + final_table_name = mapping.table_name + except FeishuTableMapping.DoesNotExist: + # 如果找不到映射,自动创建一个 + default_table_name = f"feishu_{feishu_table_name.lower().replace(' ', '_').replace('-', '_')}" + mapping, _ = DataSyncService.get_or_create_table_mapping( + app_token, + table_id, + table_url, + feishu_table_name, + table_name or default_table_name + ) + final_table_name = mapping.table_name + else: + # 非自动同步模式,优先使用用户提供的表名 + default_table_name = f"feishu_{feishu_table_name.lower().replace(' ', '_').replace('-', '_')}" + final_table_name = table_name or default_table_name + + # 创建或更新映射 + mapping, _ = DataSyncService.get_or_create_table_mapping( + app_token, + table_id, + table_url, + feishu_table_name, + final_table_name + ) + + # 3. 获取字段信息 + fields = BitableService.get_table_fields(app_token, table_id, access_token) + + # 4. 创建模型 + model = DataSyncService.create_model_from_fields(final_table_name, fields) + + # 5. 检查表是否存在,不存在则创建 + table_exists = DataSyncService.check_table_exists(final_table_name) + if not table_exists: + DataSyncService.create_table_from_model(model) + logger.info(f"表 {final_table_name} 创建成功") + else: + logger.info(f"表 {final_table_name} 已存在,将按 feishu_record_id 更新或追加数据") + + # 6. 分页获取所有记录 + all_records = [] + page_token = None + page_size = 100 + + while True: + # 查询记录 + result = BitableService.search_records( + app_token=app_token, + table_id=table_id, + access_token=access_token, + page_size=page_size, + page_token=page_token + ) + + records = result.get('items', []) + all_records.extend(records) + + # 检查是否有更多数据 + page_token = result.get('page_token') + if not page_token or not records: + break + + # 7. 同步数据到数据库 + with transaction.atomic(): + # 统计数据 + created_count = 0 + updated_count = 0 + + for record in all_records: + record_id = record.get('record_id') + fields_data = record.get('fields', {}) + + # 准备数据 + data = {'feishu_record_id': record_id} + + # 处理每个字段的数据 + for field_name, field_value in fields_data.items(): + # 将字段名转换为Python合法标识符 + db_field_name = field_name.lower().replace(' ', '_').replace('-', '_') + + # 跳过已保留的字段名 + if db_field_name in ['id', 'created_at', 'updated_at']: + continue + + # 确保字段存在于模型中 + if hasattr(model, db_field_name): + # 处理不同类型的字段值 + if isinstance(field_value, (list, dict)): + data[db_field_name] = json.dumps(field_value) + else: + data[db_field_name] = field_value + + # 尝试更新或创建记录 + try: + # 总是使用 feishu_record_id 作为唯一标识符进行更新或创建 + obj, created = model.objects.update_or_create( + feishu_record_id=record_id, + defaults=data + ) + + if created: + created_count += 1 + else: + updated_count += 1 + + except Exception as e: + logger.error(f"更新或创建记录失败: {str(e)}, 记录ID: {record_id}") + continue + + # 更新映射表中的记录数 + mapping.total_records = len(all_records) + mapping.save(update_fields=['total_records', 'last_sync_time']) + + return { + 'success': True, + 'table_name': final_table_name, + 'feishu_table_name': feishu_table_name, + 'total_records': len(all_records), + 'created_count': created_count, + 'updated_count': updated_count, + 'app_token': app_token, + 'table_id': table_id, + } + + except Exception as e: + logger.error(f"数据同步失败: {str(e)}") + return { + 'success': False, + 'error': str(e) + } \ No newline at end of file diff --git a/apps/feishu/services/feishu_service.py b/apps/feishu/services/feishu_service.py new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/apps/feishu/services/feishu_service.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/feishu/services/gmail_extraction_service.py b/apps/feishu/services/gmail_extraction_service.py new file mode 100644 index 0000000..85f48aa --- /dev/null +++ b/apps/feishu/services/gmail_extraction_service.py @@ -0,0 +1,153 @@ +import logging +import re +import json +from django.db import transaction +from apps.gmail.models import GmailConversation +from apps.accounts.models import User +from ..models import FeishuTableMapping +from .bitable_service import BitableService +from apps.gmail.services.gmail_service import GmailService + +logger = logging.getLogger(__name__) + +class GmailExtractionService: + """ + 从飞书多维表格中提取Gmail邮箱并创建对话的服务 + """ + + @staticmethod + def extract_gmail_addresses(text): + """ + 从文本中提取Gmail邮箱地址 + + Args: + text: 需要提取邮箱的文本 + + Returns: + list: Gmail邮箱地址列表 + """ + if not text: + return [] + + # Gmail邮箱正则表达式模式 + gmail_pattern = r'[a-zA-Z0-9._%+-]+@gmail\.com' + + # 查找所有匹配 + matches = re.findall(gmail_pattern, text.lower()) + + # 返回唯一的Gmail地址 + return list(set(matches)) + + @staticmethod + def find_duplicate_emails(db_table_name, feishu_table_url, access_token, email_field_name, user): + """ + 查找数据库表和飞书多维表格中重复的Gmail邮箱 + + Args: + db_table_name: 数据库表名 + feishu_table_url: 飞书多维表格URL + access_token: 访问令牌 + email_field_name: 包含Gmail邮箱的字段名 + user: 当前用户对象 + + Returns: + tuple: (邮箱列表, 错误信息) + """ + try: + # 从URL中提取app_token和table_id + app_token, table_id = BitableService.extract_params_from_url(feishu_table_url) + + # 获取飞书表格中的所有记录 + feishu_records = BitableService.search_records( + app_token=app_token, + table_id=table_id, + access_token=access_token, + page_size=1000 # 获取足够多的记录 + ) + + if not feishu_records or 'items' not in feishu_records: + return None, "无法获取飞书表格数据" + + # 提取每条记录中的Gmail邮箱 + feishu_emails = [] + for record in feishu_records.get('items', []): + field_data = record.get('fields', {}) + if email_field_name in field_data: + email_value = field_data[email_field_name] + if isinstance(email_value, str): + gmail_addresses = GmailExtractionService.extract_gmail_addresses(email_value) + feishu_emails.extend(gmail_addresses) + + # 确保邮箱地址唯一 + feishu_emails = list(set(feishu_emails)) + + logger.info(f"从飞书表格中提取出 {len(feishu_emails)} 个Gmail邮箱") + + return feishu_emails, None + + except Exception as e: + logger.error(f"查找重复的Gmail邮箱失败: {str(e)}") + return None, f"查找重复的Gmail邮箱失败: {str(e)}" + + @staticmethod + @transaction.atomic + def create_conversations_for_emails(user, user_email, emails, kb_id=None): + """ + 为提取的Gmail邮箱创建对话 + + Args: + user: 当前用户对象 + user_email: 用户Gmail邮箱 + emails: 达人Gmail邮箱列表 + kb_id: 知识库ID + + Returns: + tuple: (成功创建的对话数量, 错误信息) + """ + try: + if not emails: + return 0, "没有提供邮箱列表" + + success_count = 0 + failed_emails = [] + + for email in emails: + try: + # 检查是否已存在对话 + existing_conversation = GmailConversation.objects.filter( + user=user, + user_email=user_email, + influencer_email=email + ).exists() + + if existing_conversation: + logger.info(f"用户 {user.username} 与 {email} 的对话已存在,跳过") + continue + + # 创建新对话 + conversation_id, error = GmailService.save_conversations_to_chat( + user=user, + user_email=user_email, + influencer_email=email, + kb_id=kb_id + ) + + if conversation_id: + success_count += 1 + logger.info(f"成功创建与 {email} 的对话,ID: {conversation_id}") + else: + logger.error(f"创建与 {email} 的对话失败: {error}") + failed_emails.append(email) + + except Exception as e: + logger.error(f"处理邮箱 {email} 时出错: {str(e)}") + failed_emails.append(email) + + if failed_emails: + return success_count, f"成功创建 {success_count} 个对话,失败 {len(failed_emails)} 个: {', '.join(failed_emails[:5])}{' 等' if len(failed_emails) > 5 else ''}" + else: + return success_count, None + + except Exception as e: + logger.error(f"创建Gmail对话失败: {str(e)}") + return 0, f"创建Gmail对话失败: {str(e)}" \ No newline at end of file diff --git a/apps/feishu/urls.py b/apps/feishu/urls.py index e69de29..d1a3570 100644 --- a/apps/feishu/urls.py +++ b/apps/feishu/urls.py @@ -0,0 +1,20 @@ +from django.urls import path +from .views import ( + FeishuTableRecordsView, + FeishuDataSyncView, + FeishuTableMappingListView, + FeishuTableMappingDetailView, + GmailExtractionView, + AutoGmailConversationView, + +) + +urlpatterns = [ + path('table-records/', FeishuTableRecordsView.as_view(), name='feishu-table-records'), + path('sync-data/', FeishuDataSyncView.as_view(), name='feishu-sync-data'), + path('mappings/', FeishuTableMappingListView.as_view(), name='feishu-table-mappings'), + path('mappings//', FeishuTableMappingDetailView.as_view(), name='feishu-table-mapping-detail'), + path('extract-gmail/', GmailExtractionView.as_view(), name='gmail-extraction'), + path('auto-conversations/', AutoGmailConversationView.as_view(), name='auto-conversations'), + +] diff --git a/apps/feishu/views.py b/apps/feishu/views.py index 0bfe8e8..49261e8 100644 --- a/apps/feishu/views.py +++ b/apps/feishu/views.py @@ -1,55 +1,418 @@ -# apps/feishu/models.py -from django.db import models -from django.utils import timezone +import json +import traceback +import logging import uuid +from django.db import transaction +from django.utils import timezone +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from django.http import Http404 +from .models import FeishuTableMapping +from .services.bitable_service import BitableService +from .services.data_sync_service import DataSyncService +from .services.gmail_extraction_service import GmailExtractionService +from .services.auto_gmail_conversation_service import AutoGmailConversationService +from rest_framework.permissions import IsAuthenticated +from apps.gmail.models import GmailCredential, GmailConversation, AutoReplyConfig +from apps.gmail.services.gmail_service import GmailService +from apps.gmail.serializers import AutoReplyConfigSerializer -class FeishuCreator(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - record_id = models.CharField(max_length=100, unique=True, verbose_name='飞书记录ID') - - # 对接人信息 - contact_person = models.CharField(max_length=50, blank=True, verbose_name='对接人') - - # 基本账号信息 - handle = models.TextField(blank=True, verbose_name='Handle') - tiktok_url = models.TextField(blank=True, verbose_name='链接') - fans_count = models.CharField(max_length=50, blank=True, verbose_name='粉丝数') - gmv = models.CharField(max_length=100, blank=True, verbose_name='GMV') - - # 联系方式 - email = models.EmailField(blank=True, verbose_name='邮箱') - phone = models.CharField(max_length=50, blank=True, verbose_name='手机号|WhatsApp') - - # 账号属性和报价 - account_type = models.CharField(max_length=50, blank=True, verbose_name='账号属性') - price_quote = models.TextField(blank=True, verbose_name='报价') - response_speed = models.CharField(max_length=50, blank=True, verbose_name='回复速度') - - # 合作相关 - cooperation_intention = models.CharField(max_length=50, blank=True, verbose_name='合作意向') - payment_method = models.CharField(max_length=50, blank=True, verbose_name='支付方式') - payment_account = models.CharField(max_length=100, blank=True, verbose_name='收款账号') - address = models.TextField(blank=True, verbose_name='收件地址') - has_ooin = models.CharField(max_length=10, blank=True, verbose_name='签约OOIN?') - - # 渠道和进度 - source = models.CharField(max_length=100, blank=True, verbose_name='渠道来源') - contact_status = models.CharField(max_length=50, blank=True, verbose_name='建联进度') - cooperation_brands = models.JSONField(default=list, blank=True, verbose_name='合作品牌') - - # 品类信息 - system_categories = models.CharField(max_length=100, blank=True, verbose_name='系统展示的带货品类') - actual_categories = models.CharField(max_length=100, blank=True, verbose_name='实际高播放量带货品类') - human_categories = models.CharField(max_length=100, blank=True, verbose_name='达人标想要货品类') - - # 其他信息 - creator_base = models.CharField(max_length=100, blank=True, verbose_name='达人base') - notes = models.TextField(blank=True, verbose_name='父记录') - - created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') - updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') - class Meta: - db_table = 'feishu_creators' - verbose_name = '创作者数据' - verbose_name_plural = '创作者数据' \ No newline at end of file +logger = logging.getLogger(__name__) + + +class FeishuTableRecordsView(APIView): + """ + 查询飞书多维表格数据的视图 + """ + def post(self, request, *args, **kwargs): + try: + # 获取前端传来的数据 + data = request.data + table_url = data.get('table_url') + access_token = data.get('access_token') + filter_exp = data.get('filter') + sort = data.get('sort') + page_size = data.get('page_size', 20) + page_token = data.get('page_token') + + if not table_url or not access_token: + return Response( + {"error": "请提供多维表格URL和access_token"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + # 从URL中提取app_token和table_id + app_token, table_id = BitableService.extract_params_from_url(table_url) + + # 先获取一些样本数据,检查我们能否访问多维表格 + sample_data = BitableService.search_records( + app_token=app_token, + table_id=table_id, + access_token=access_token, + filter_exp=filter_exp, + sort=sort, + page_size=page_size, + page_token=page_token + ) + + return Response(sample_data, status=status.HTTP_200_OK) + + except ValueError as ve: + return Response( + {"error": str(ve), "details": "URL格式可能不正确"}, + status=status.HTTP_400_BAD_REQUEST + ) + + except Exception as e: + error_details = traceback.format_exc() + return Response( + { + "error": f"查询飞书多维表格失败: {str(e)}", + "details": error_details[:500] # 限制错误详情长度 + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +class FeishuDataSyncView(APIView): + """ + 将飞书多维表格数据同步到数据库的视图 + """ + def post(self, request, *args, **kwargs): + try: + # 获取前端传来的数据 + data = request.data + table_url = data.get('table_url') + access_token = data.get('access_token') + table_name = data.get('table_name') # 可选,自定义表名 + primary_key = data.get('primary_key') # 可选,指定主键 + + if not table_url or not access_token: + return Response( + {"error": "请提供多维表格URL和access_token"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 提取参数 + try: + app_token, table_id = BitableService.extract_params_from_url(table_url) + + # 先获取一些样本数据,检查我们能否访问多维表格 + sample_data = BitableService.search_records( + app_token=app_token, + table_id=table_id, + access_token=access_token, + page_size=5 + ) + + # 执行数据同步 + result = DataSyncService.sync_data_to_db( + table_url=table_url, + access_token=access_token, + table_name=table_name, + primary_key=primary_key + ) + + # 添加样本数据到结果中 + if result.get('success'): + result['sample_data'] = sample_data.get('items', [])[:3] # 只返回最多3条样本数据 + return Response(result, status=status.HTTP_200_OK) + else: + return Response(result, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + except ValueError as ve: + return Response( + {"error": str(ve), "details": "URL格式可能不正确"}, + status=status.HTTP_400_BAD_REQUEST + ) + + except Exception as e: + import traceback + error_details = traceback.format_exc() + return Response( + { + "error": f"数据同步失败: {str(e)}", + "details": error_details[:500] # 限制错误详情长度 + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +class FeishuTableMappingListView(APIView): + """ + 获取已映射表格列表的视图 + """ + def get(self, request, format=None): + """获取所有映射的表格列表""" + mappings = FeishuTableMapping.objects.all().order_by('-last_sync_time') + + result = [] + for mapping in mappings: + result.append({ + 'id': mapping.id, + 'app_token': mapping.app_token, + 'table_id': mapping.table_id, + 'table_url': mapping.table_url, + 'table_name': mapping.table_name, + 'feishu_table_name': mapping.feishu_table_name, + 'last_sync_time': mapping.last_sync_time.strftime('%Y-%m-%d %H:%M:%S') if mapping.last_sync_time else None, + 'total_records': mapping.total_records + }) + + return Response(result) + + +class FeishuTableMappingDetailView(APIView): + """ + 单个表格映射的操作视图 + """ + def get_object(self, pk): + try: + return FeishuTableMapping.objects.get(pk=pk) + except FeishuTableMapping.DoesNotExist: + raise Http404 + + def get(self, request, pk, format=None): + """获取单个映射详情""" + mapping = self.get_object(pk) + + return Response({ + 'id': mapping.id, + 'app_token': mapping.app_token, + 'table_id': mapping.table_id, + 'table_url': mapping.table_url, + 'table_name': mapping.table_name, + 'feishu_table_name': mapping.feishu_table_name, + 'last_sync_time': mapping.last_sync_time.strftime('%Y-%m-%d %H:%M:%S') if mapping.last_sync_time else None, + 'total_records': mapping.total_records + }) + + def post(self, request, pk, format=None): + """同步单个表格数据""" + mapping = self.get_object(pk) + + # 获取access_token + access_token = request.data.get('access_token') + if not access_token: + return Response( + {"error": "请提供access_token"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 执行数据同步 + result = DataSyncService.sync_data_to_db( + table_url=mapping.table_url, + access_token=access_token, + table_name=mapping.table_name, + auto_sync=True + ) + + if result.get('success'): + return Response(result, status=status.HTTP_200_OK) + else: + return Response(result, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + def delete(self, request, pk, format=None): + """删除映射关系(不删除数据表)""" + mapping = self.get_object(pk) + mapping.delete() + + return Response({"message": "映射关系已删除"}, status=status.HTTP_204_NO_CONTENT) + + +class GmailExtractionView(APIView): + """ + 从飞书多维表格中提取Gmail邮箱并创建对话 + """ + @transaction.atomic + def post(self, request, *args, **kwargs): + try: + # 获取请求数据 + data = request.data + table_url = data.get('table_url') + access_token = data.get('access_token') + email_field_name = data.get('email_field_name') + user_email = data.get('user_email') + kb_id = data.get('kb_id') + + # 验证必填字段 + if not table_url or not access_token or not email_field_name or not user_email: + return Response( + {"error": "请提供必要参数: table_url, access_token, email_field_name, user_email"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 提取Gmail邮箱 + gmail_emails, error = GmailExtractionService.find_duplicate_emails( + db_table_name=None, # 不需要检查数据库表 + feishu_table_url=table_url, + access_token=access_token, + email_field_name=email_field_name, + user=request.user + ) + + if error: + return Response({"error": error}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + if not gmail_emails: + return Response({"message": "未找到Gmail邮箱"}, status=status.HTTP_200_OK) + + # 创建对话 + success_count, error = GmailExtractionService.create_conversations_for_emails( + user=request.user, + user_email=user_email, + emails=gmail_emails, + kb_id=kb_id + ) + + return Response({ + "success": True, + "message": f"成功创建 {success_count} 个Gmail对话", + "total_emails": len(gmail_emails), + "gmail_emails": gmail_emails[:20] if len(gmail_emails) > 20 else gmail_emails, + "error": error + }, status=status.HTTP_200_OK) + + except Exception as e: + error_details = traceback.format_exc() + return Response( + { + "error": f"提取Gmail邮箱并创建对话失败: {str(e)}", + "details": error_details[:500] + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +class AutoGmailConversationView(APIView): + """ + 自动Gmail对话API,支持自动发送消息并实时接收和回复达人消息 + """ + permission_classes = [IsAuthenticated] + + def post(self, request, *args, **kwargs): + """ + 创建自动对话,发送第一条打招呼消息 + + 请求参数: + - user_email: 用户的Gmail邮箱(已授权) + - influencer_email: 达人Gmail邮箱 + - goal_description: 对话目标描述 + """ + try: + # 获取请求数据 + data = request.data + user_email = data.get('user_email') + influencer_email = data.get('influencer_email') + goal_description = data.get('goal_description') + + # 验证必填参数 + if not user_email or not influencer_email or not goal_description: + return Response({ + 'code': 400, + 'message': '缺少必要参数: user_email, influencer_email, goal_description', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 获取或创建活跃对话 + # 先查找现有对话 + existing_conversation = GmailConversation.objects.filter( + user=request.user, + user_email=user_email, + influencer_email=influencer_email + ).first() + + if existing_conversation: + # 激活现有对话 + existing_conversation.is_active = True + existing_conversation.save() + logger.info(f"找到并激活现有对话: {existing_conversation.conversation_id}") + + # 调用服务创建自动对话 + success, result, goal = AutoGmailConversationService.create_auto_conversation( + user=request.user, + user_email=user_email, + influencer_email=influencer_email, + greeting_message="", # 使用空字符串,实际消息在服务中已固定 + goal_description=goal_description + ) + + if not success: + return Response({ + 'code': 400, + 'message': result, + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 确保对话是活跃的 + conversation = GmailConversation.objects.filter(conversation_id=result).first() + if conversation and not conversation.is_active: + conversation.is_active = True + conversation.save() + logger.info(f"已将对话 {conversation.conversation_id} 设置为活跃") + + # 设置Gmail推送通知 + notification_result, notification_error = GmailService.setup_gmail_push_notification( + user=request.user, + user_email=user_email + ) + + if not notification_result and notification_error: + logger.warning(f"设置Gmail推送通知失败: {notification_error},但对话创建成功") + + # 返回结果 + return Response({ + 'code': 201, + 'message': '自动对话创建成功,已发送打招呼消息', + 'data': { + 'conversation_id': result, + 'goal_id': str(goal.id) if goal else None, + 'user_email': user_email, + 'influencer_email': influencer_email, + 'is_active': True, + 'goal_description': goal_description, + 'push_notification': notification_result + } + }, status=status.HTTP_201_CREATED) + + except Exception as e: + logger.error(f"创建自动对话失败: {str(e)}") + error_details = traceback.format_exc() + return Response({ + 'code': 500, + 'message': f'创建自动对话失败: {str(e)}', + 'data': { + 'details': error_details[:500] + } + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + def get(self, request, *args, **kwargs): + """ + 获取用户所有自动对话及其状态 + """ + try: + # 获取用户所有对话和目标 + conversations = AutoGmailConversationService.get_all_user_conversations_with_goals(request.user) + + return Response({ + 'code': 200, + 'message': '获取自动对话列表成功', + 'data': { + 'conversations': conversations + } + }) + + except Exception as e: + logger.error(f"获取自动对话列表失败: {str(e)}") + error_details = traceback.format_exc() + return Response({ + 'code': 500, + 'message': f'获取自动对话列表失败: {str(e)}', + 'data': { + 'details': error_details[:500] + } + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + diff --git a/apps/gmail/migrations/0005_usergoal.py b/apps/gmail/migrations/0005_usergoal.py new file mode 100644 index 0000000..760772c --- /dev/null +++ b/apps/gmail/migrations/0005_usergoal.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2 on 2025-05-14 02:52 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gmail', '0004_conversationsummary'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='UserGoal', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('description', models.TextField(verbose_name='目标描述')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('is_active', models.BooleanField(default=True, verbose_name='是否激活')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='goals', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': '用户目标', + 'verbose_name_plural': '用户目标', + 'db_table': 'user_goals', + 'ordering': ['-updated_at'], + }, + ), + ] diff --git a/apps/gmail/migrations/0006_usergoal_completion_time_usergoal_conversation_and_more.py b/apps/gmail/migrations/0006_usergoal_completion_time_usergoal_conversation_and_more.py new file mode 100644 index 0000000..124a447 --- /dev/null +++ b/apps/gmail/migrations/0006_usergoal_completion_time_usergoal_conversation_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2 on 2025-05-14 09:45 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gmail', '0005_usergoal'), + ] + + operations = [ + migrations.AddField( + model_name='usergoal', + name='completion_time', + field=models.DateTimeField(blank=True, null=True, verbose_name='完成时间'), + ), + migrations.AddField( + model_name='usergoal', + name='conversation', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='goals', to='gmail.gmailconversation'), + ), + migrations.AddField( + model_name='usergoal', + name='last_activity_time', + field=models.DateTimeField(blank=True, null=True, verbose_name='最后活动时间'), + ), + migrations.AddField( + model_name='usergoal', + name='metadata', + field=models.JSONField(blank=True, default=dict, help_text='存储额外信息', null=True), + ), + migrations.AddField( + model_name='usergoal', + name='status', + field=models.CharField(choices=[('pending', '待处理'), ('in_progress', '进行中'), ('completed', '已完成'), ('failed', '失败')], default='pending', max_length=20, verbose_name='目标状态'), + ), + ] diff --git a/apps/gmail/migrations/0007_alter_gmailattachment_attachment_id.py b/apps/gmail/migrations/0007_alter_gmailattachment_attachment_id.py new file mode 100644 index 0000000..9334986 --- /dev/null +++ b/apps/gmail/migrations/0007_alter_gmailattachment_attachment_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2025-05-19 07:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gmail', '0006_usergoal_completion_time_usergoal_conversation_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='gmailattachment', + name='attachment_id', + field=models.CharField(help_text='Gmail附件的唯一标识符,可能很长', max_length=255), + ), + ] diff --git a/apps/gmail/migrations/0008_alter_gmailattachment_attachment_id.py b/apps/gmail/migrations/0008_alter_gmailattachment_attachment_id.py new file mode 100644 index 0000000..8ae3d49 --- /dev/null +++ b/apps/gmail/migrations/0008_alter_gmailattachment_attachment_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2025-05-19 07:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gmail', '0007_alter_gmailattachment_attachment_id'), + ] + + operations = [ + migrations.AlterField( + model_name='gmailattachment', + name='attachment_id', + field=models.TextField(help_text='Gmail附件的唯一标识符,可能很长'), + ), + ] diff --git a/apps/gmail/migrations/0009_gmailconversation_has_sent_greeting_and_more.py b/apps/gmail/migrations/0009_gmailconversation_has_sent_greeting_and_more.py new file mode 100644 index 0000000..dd45b75 --- /dev/null +++ b/apps/gmail/migrations/0009_gmailconversation_has_sent_greeting_and_more.py @@ -0,0 +1,82 @@ +# Generated by Django 5.2 on 2025-05-20 06:52 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gmail', '0008_alter_gmailattachment_attachment_id'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='gmailconversation', + name='has_sent_greeting', + field=models.BooleanField(default=False, help_text='Whether a greeting message has been sent to this conversation'), + ), + migrations.AlterField( + model_name='gmailconversation', + name='conversation_id', + field=models.CharField(help_text='Unique conversation identifier', max_length=100, unique=True), + ), + migrations.AlterField( + model_name='gmailconversation', + name='influencer_email', + field=models.EmailField(help_text="Influencer's email address", max_length=254), + ), + migrations.AlterField( + model_name='gmailconversation', + name='is_active', + field=models.BooleanField(default=True, help_text='Whether this conversation is active'), + ), + migrations.AlterField( + model_name='gmailconversation', + name='last_sync_time', + field=models.DateTimeField(blank=True, help_text='Last time conversation was synced with Gmail', null=True), + ), + migrations.AlterField( + model_name='gmailconversation', + name='metadata', + field=models.JSONField(blank=True, help_text='Additional metadata for the conversation', null=True), + ), + migrations.AlterField( + model_name='gmailconversation', + name='title', + field=models.CharField(help_text='Conversation title', max_length=255), + ), + migrations.AlterField( + model_name='gmailconversation', + name='user_email', + field=models.EmailField(help_text="User's Gmail address", max_length=254), + ), + migrations.CreateModel( + name='AutoReplyConfig', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('user_email', models.EmailField(help_text='用户Gmail邮箱', max_length=254)), + ('influencer_email', models.EmailField(help_text='达人Gmail邮箱', max_length=254)), + ('is_enabled', models.BooleanField(default=True, help_text='是否启用自动回复')), + ('goal_description', models.TextField(help_text='AI回复时参考的目标', verbose_name='自动回复的目标描述')), + ('reply_template', models.TextField(blank=True, help_text='回复模板(可选,为空则由AI生成)', null=True)), + ('max_replies', models.IntegerField(default=5, help_text='最大自动回复次数')), + ('current_replies', models.IntegerField(default=0, help_text='当前已自动回复次数')), + ('last_reply_time', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('metadata', models.JSONField(blank=True, default=dict, help_text='存储额外信息,如已处理的消息ID等', null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auto_reply_configs', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Gmail自动回复配置', + 'verbose_name_plural': 'Gmail自动回复配置', + 'db_table': 'gmail_auto_reply_configs', + 'ordering': ['-updated_at'], + 'unique_together': {('user', 'user_email', 'influencer_email')}, + }, + ), + ] diff --git a/apps/gmail/models.py b/apps/gmail/models.py index 036475a..fc2b1f8 100644 --- a/apps/gmail/models.py +++ b/apps/gmail/models.py @@ -47,15 +47,16 @@ class GmailCredential(models.Model): class GmailConversation(models.Model): """Gmail对话记录,跟踪用户和达人之间的邮件交互""" user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='gmail_conversations') - user_email = models.EmailField(help_text="用户Gmail邮箱") - influencer_email = models.EmailField(help_text="达人Gmail邮箱") - conversation_id = models.CharField(max_length=100, unique=True, help_text="关联到chat_history的会话ID") - title = models.CharField(max_length=100, default="Gmail对话", help_text="对话标题") - last_sync_time = models.DateTimeField(default=timezone.now, help_text="最后同步时间") + conversation_id = models.CharField(max_length=100, unique=True, help_text="Unique conversation identifier") + user_email = models.EmailField(help_text="User's Gmail address") + influencer_email = models.EmailField(help_text="Influencer's email address") + title = models.CharField(max_length=255, help_text="Conversation title") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - is_active = models.BooleanField(default=True) - metadata = models.JSONField(default=dict, blank=True, null=True, help_text="存储额外信息,如已处理的消息ID等") + last_sync_time = models.DateTimeField(null=True, blank=True, help_text="Last time conversation was synced with Gmail") + is_active = models.BooleanField(default=True, help_text="Whether this conversation is active") + has_sent_greeting = models.BooleanField(default=False, help_text="Whether a greeting message has been sent to this conversation") + metadata = models.JSONField(null=True, blank=True, help_text="Additional metadata for the conversation") def __str__(self): return f"{self.user.username}: {self.user_email} - {self.influencer_email}" @@ -68,7 +69,7 @@ class GmailAttachment(models.Model): """Gmail附件记录""" conversation = models.ForeignKey(GmailConversation, on_delete=models.CASCADE, related_name='attachments') email_message_id = models.CharField(max_length=100, help_text="Gmail邮件ID") - attachment_id = models.CharField(max_length=100, help_text="Gmail附件ID") + attachment_id = models.TextField(help_text="Gmail附件的唯一标识符,可能很长") filename = models.CharField(max_length=255, help_text="原始文件名") file_path = models.CharField(max_length=255, help_text="保存在服务器上的路径") content_type = models.CharField(max_length=100, help_text="MIME类型") @@ -107,4 +108,78 @@ class ConversationSummary(models.Model): verbose_name_plural = 'Gmail对话摘要' def __str__(self): - return f"对话 {self.conversation.id} 摘要" \ No newline at end of file + return f"对话 {self.conversation.id} 摘要" + +class UserGoal(models.Model): + """用户目标模型 - 存储用户设定的沟通或销售目标""" + STATUS_CHOICES = [ + ('pending', '待处理'), + ('in_progress', '进行中'), + ('completed', '已完成'), + ('failed', '失败') + ] + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='goals') + conversation = models.ForeignKey(GmailConversation, on_delete=models.CASCADE, related_name='goals', null=True) + description = models.TextField(verbose_name='目标描述') + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name='目标状态') + is_active = models.BooleanField(default=True, verbose_name='是否激活') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + completion_time = models.DateTimeField(null=True, blank=True, verbose_name='完成时间') + last_activity_time = models.DateTimeField(null=True, blank=True, verbose_name='最后活动时间') + metadata = models.JSONField(default=dict, blank=True, null=True, help_text="存储额外信息") + + class Meta: + db_table = 'user_goals' + verbose_name = '用户目标' + verbose_name_plural = '用户目标' + ordering = ['-updated_at'] + + def __str__(self): + return f"{self.user.username}的目标 - {self.description[:20]}..." + + def get_conversation_id(self): + """获取对话ID""" + if self.conversation: + return self.conversation.conversation_id + return None + +class AutoReplyConfig(models.Model): + """ + 自动回复配置模型 - 设置特定用户Gmail与达人Gmail的自动回复规则 + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='auto_reply_configs') + user_email = models.EmailField(help_text="用户Gmail邮箱") + influencer_email = models.EmailField(help_text="达人Gmail邮箱") + is_enabled = models.BooleanField(default=True, help_text="是否启用自动回复") + goal_description = models.TextField(verbose_name='自动回复的目标描述', help_text="AI回复时参考的目标") + reply_template = models.TextField(blank=True, null=True, help_text="回复模板(可选,为空则由AI生成)") + max_replies = models.IntegerField(default=5, help_text="最大自动回复次数") + current_replies = models.IntegerField(default=0, help_text="当前已自动回复次数") + last_reply_time = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + metadata = models.JSONField(default=dict, blank=True, null=True, help_text="存储额外信息,如已处理的消息ID等") + + class Meta: + db_table = 'gmail_auto_reply_configs' + verbose_name = 'Gmail自动回复配置' + verbose_name_plural = 'Gmail自动回复配置' + unique_together = ('user', 'user_email', 'influencer_email') + ordering = ['-updated_at'] + + def __str__(self): + return f"{self.user.username}: {self.user_email} ➔ {self.influencer_email}" + + def can_reply(self): + """检查是否可以继续自动回复""" + return self.is_enabled and self.current_replies < self.max_replies + + def increment_reply_count(self): + """增加回复计数并更新时间""" + self.current_replies += 1 + self.last_reply_time = timezone.now() + self.save(update_fields=['current_replies', 'last_reply_time', 'updated_at']) \ No newline at end of file diff --git a/apps/gmail/serializers.py b/apps/gmail/serializers.py index e236ee2..7220faf 100644 --- a/apps/gmail/serializers.py +++ b/apps/gmail/serializers.py @@ -1,69 +1,95 @@ from rest_framework import serializers -from .models import GmailCredential +from .models import GmailCredential, GmailConversation, GmailAttachment, UserGoal, AutoReplyConfig +from apps.accounts.models import User import json class GmailCredentialSerializer(serializers.ModelSerializer): - client_secret_json = serializers.JSONField(write_only=True, required=False, allow_null=True) - client_secret_file = serializers.FileField(write_only=True, required=False, allow_null=True) + """Gmail账号凭证的序列化器""" + + # 额外字段,用于OAuth流程 + client_secret_json = serializers.CharField(write_only=True, required=False) auth_code = serializers.CharField(write_only=True, required=False, allow_blank=True) - email = serializers.EmailField(required=False, allow_blank=True) # Make email optional - + class Meta: model = GmailCredential - fields = ['id', 'email', 'is_default', 'created_at', 'updated_at', 'is_valid', - 'client_secret_json', 'client_secret_file', 'auth_code'] - read_only_fields = ['created_at', 'updated_at', 'is_valid'] - - def validate(self, data): - """Validate client_secret input (either JSON or file).""" - client_secret_json = data.get('client_secret_json') - client_secret_file = data.get('client_secret_file') - auth_code = data.get('auth_code') - - # For auth initiation, only client_secret is required - if not auth_code: # Initiation phase - if not client_secret_json and not client_secret_file: - raise serializers.ValidationError( - "Either client_secret_json or client_secret_file is required." - ) - if client_secret_json and client_secret_file: - raise serializers.ValidationError( - "Provide only one of client_secret_json or client_secret_file." - ) - - # For auth completion, both auth_code and client_secret are required - if auth_code and not (client_secret_json or client_secret_file): - raise serializers.ValidationError( - "client_secret_json or client_secret_file is required with auth_code." - ) - - # Parse client_secret_json if provided - if client_secret_json: - try: - json.dumps(client_secret_json) - except (TypeError, ValueError): - raise serializers.ValidationError("client_secret_json must be valid JSON.") - - # Parse client_secret_file if provided - if client_secret_file: - try: - content = client_secret_file.read().decode('utf-8') - client_secret_json = json.loads(content) - data['client_secret_json'] = client_secret_json - except (json.JSONDecodeError, UnicodeDecodeError): - raise serializers.ValidationError("client_secret_file must contain valid JSON.") - + fields = ['id', 'user', 'email', 'is_default', 'is_valid', 'created_at', 'updated_at', 'client_secret_json', 'auth_code'] + read_only_fields = ['id', 'user', 'email', 'is_valid', 'created_at', 'updated_at'] + + def validate_client_secret_json(self, value): + """验证client_secret_json是否为有效的JSON""" + try: + json.loads(value) + return value + except Exception as e: + raise serializers.ValidationError(f"Invalid JSON: {str(e)}") + + def to_representation(self, instance): + """自定义数据表示方式,确保不泄露敏感数据""" + data = super().to_representation(instance) + # 移除敏感字段 + if 'client_secret_json' in data: + del data['client_secret_json'] + if 'auth_code' in data: + del data['auth_code'] return data - def validate_email(self, value): - """Ensure email is unique for the user (only for completion).""" - if not value: # Email is optional during initiation - return value +class SimpleGmailConversationSerializer(serializers.ModelSerializer): + """Gmail对话的简易序列化器""" + class Meta: + model = GmailConversation + fields = ['conversation_id', 'user_email', 'influencer_email', 'title', 'created_at', 'updated_at', 'is_active'] + +class GmailAttachmentSerializer(serializers.ModelSerializer): + """Gmail附件的序列化器""" + url = serializers.SerializerMethodField() + + class Meta: + model = GmailAttachment + fields = ['id', 'filename', 'content_type', 'size', 'sender_email', 'created_at', 'url'] + + def get_url(self, obj): + """获取附件URL""" + return obj.get_absolute_url() + +class UserGoalSerializer(serializers.ModelSerializer): + """用户目标的序列化器""" + conversation_id = serializers.CharField(write_only=True, required=False) + + class Meta: + model = UserGoal + fields = ['id', 'user', 'conversation', 'description', 'status', 'is_active', + 'created_at', 'updated_at', 'completion_time', 'last_activity_time', + 'conversation_id', 'metadata'] + read_only_fields = ['id', 'user', 'created_at', 'updated_at', 'completion_time', 'last_activity_time'] + + def to_representation(self, instance): + """自定义数据表示方式""" + data = super().to_representation(instance) + # 添加conversation_id方便前端使用 + if instance.conversation: + data['conversation_id'] = instance.conversation.conversation_id + return data + +class AutoReplyConfigSerializer(serializers.ModelSerializer): + """自动回复配置的序列化器""" + + class Meta: + model = AutoReplyConfig + fields = ['id', 'user', 'user_email', 'influencer_email', 'is_enabled', + 'goal_description', 'reply_template', 'max_replies', 'current_replies', + 'last_reply_time', 'created_at', 'updated_at', 'metadata'] + read_only_fields = ['id', 'user', 'current_replies', 'last_reply_time', 'created_at', 'updated_at'] + + def validate(self, data): + """验证用户邮箱是否已授权""" user = self.context['request'].user - if self.instance: # Update case - if GmailCredential.objects.filter(user=user, email=value).exclude(id=self.instance.id).exists(): - raise serializers.ValidationError("This Gmail account is already added.") - else: # Create case - if GmailCredential.objects.filter(user=user, email=value).exists(): - raise serializers.ValidationError("This Gmail account is already added.") - return value \ No newline at end of file + user_email = data.get('user_email') + + if user_email: + # 检查用户是否已授权该邮箱 + credential = GmailCredential.objects.filter(user=user, email=user_email, is_valid=True).first() + if not credential: + raise serializers.ValidationError({"user_email": f"邮箱 {user_email} 未授权或授权已失效"}) + + return data + diff --git a/apps/gmail/services/gmail_service.py b/apps/gmail/services/gmail_service.py index 30d3786..18d4eb1 100644 --- a/apps/gmail/services/gmail_service.py +++ b/apps/gmail/services/gmail_service.py @@ -274,103 +274,8 @@ class GmailService: logger.error(f"获取Gmail对话失败: {str(e)}") return None, f"获取Gmail对话失败: {str(e)}" - @staticmethod - def _parse_email_content(message): - """ - 解析邮件内容 - - Args: - message: Gmail API返回的邮件对象 - - Returns: - dict: 邮件内容字典 - """ - try: - message_id = message['id'] - payload = message['payload'] - headers = payload['headers'] - - # 提取基本信息 - email_data = { - 'id': message_id, - 'subject': '', - 'from': '', - 'from_email': '', - 'to': '', - 'to_email': '', - 'date': '', - 'body': '', - 'attachments': [] - } - - # 提取邮件头信息 - for header in headers: - name = header['name'].lower() - if name == 'subject': - email_data['subject'] = header['value'] - elif name == 'from': - email_data['from'] = header['value'] - _, email_data['from_email'] = parseaddr(header['value']) - elif name == 'to': - email_data['to'] = header['value'] - _, email_data['to_email'] = parseaddr(header['value']) - elif name == 'date': - try: - date_value = header['value'] - # 解析日期格式并转换为标准格式 - date_obj = email.utils.parsedate_to_datetime(date_value) - email_data['date'] = date_obj.strftime('%Y-%m-%d %H:%M:%S') - except Exception as e: - logger.error(f"解析日期失败: {str(e)}") - email_data['date'] = header['value'] - - # 处理邮件正文和附件 - GmailService._process_email_parts(payload, email_data) - - return email_data - - except Exception as e: - logger.error(f"解析邮件内容失败: {str(e)}") - return None - - @staticmethod - def _process_email_parts(part, email_data, is_root=True): - """ - 递归处理邮件部分,提取正文和附件 - - Args: - part: 邮件部分 - email_data: 邮件数据字典 - is_root: 是否为根部分 - """ - if 'parts' in part: - for sub_part in part['parts']: - GmailService._process_email_parts(sub_part, email_data, False) - - # 处理附件 - if not is_root and 'filename' in part.get('body', {}) and part.get('filename'): - attachment = { - 'filename': part.get('filename', ''), - 'mimeType': part.get('mimeType', ''), - 'size': part['body'].get('size', 0) - } - - if 'attachmentId' in part['body']: - attachment['attachmentId'] = part['body']['attachmentId'] - - email_data['attachments'].append(attachment) - - # 处理正文 - mime_type = part.get('mimeType', '') - if mime_type == 'text/plain' and 'data' in part.get('body', {}): - data = part['body'].get('data', '') - if data: - try: - text = base64.urlsafe_b64decode(data).decode('utf-8') - email_data['body'] = text - except Exception as e: - logger.error(f"解码邮件正文失败: {str(e)}") - + + @staticmethod def download_attachment(user, gmail_credential, message_id, attachment_id, filename): """ @@ -415,28 +320,6 @@ class GmailService: logger.error(f"下载附件失败: {str(e)}") return None - @staticmethod - def _safe_filename(filename): - """ - 生成安全的文件名 - - Args: - filename: 原始文件名 - - Returns: - str: 安全的文件名 - """ - # 替换不安全字符 - unsafe_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|'] - for char in unsafe_chars: - filename = filename.replace(char, '_') - - # 确保文件名长度合理 - if len(filename) > 100: - base, ext = os.path.splitext(filename) - filename = base[:100] + ext - - return filename @staticmethod @transaction.atomic @@ -505,8 +388,11 @@ class GmailService: is_from_user = email_data['from_email'].lower() == user_email.lower() role = 'user' if is_from_user else 'assistant' - # 准备内容文本 - content = f"主题: {email_data['subject']}\n\n{email_data['body']}" + # 记录角色判断信息,以便调试 + logger.info(f"邮件角色判断: 发件人={email_data['from_email']}, 用户邮箱={user_email}, 判定为{'用户' if is_from_user else '达人'}") + + # 直接使用原始邮件内容,不进行任何清理或修改 + content = email_data['body'] # 创建聊天消息 chat_message = ChatHistory.objects.create( @@ -521,6 +407,7 @@ class GmailService: 'from': email_data['from'], 'to': email_data['to'], 'date': email_data['date'], + 'subject': email_data['subject'], # 将主题保存在metadata中 'source': 'gmail' } ) @@ -653,311 +540,255 @@ class GmailService: @staticmethod def process_new_emails(user, credential, history_id=None): """ - 处理新收到的邮件 + 处理新邮件,获取最新收件箱邮件,进行相关处理 Args: - user: 用户对象 + user: Django用户对象 credential: Gmail凭证对象 - history_id: Gmail历史记录ID (可选,如果不提供则使用凭证中的last_history_id) + history_id: 历史ID,如果提供则只获取该ID之后的变更 Returns: - None + list: 处理的新邮件ID列表 """ try: - # 如果没有提供history_id,使用凭证中的last_history_id - if not history_id and credential.last_history_id: - history_id = credential.last_history_id - logger.info(f"使用凭证中保存的历史ID: {history_id}") - - if not history_id: - logger.error(f"缺少历史ID,无法处理新邮件") - return - - logger.info(f"开始处理Gmail新邮件,用户: {user.username}, 邮箱: {credential.email}, 历史ID: {history_id}") - # 获取Gmail服务 service = GmailService.get_service(credential) - # 获取活跃对话 - active_conversations = GmailConversation.objects.filter( - user=user, - user_email=credential.email, - is_active=True - ) + # 获取用户邮箱 + user_email = credential.email - influencer_emails = [conv.influencer_email for conv in active_conversations] - if not influencer_emails: - logger.info(f"用户 {user.username} 没有活跃的Gmail对话") - return - - logger.info(f"找到 {len(influencer_emails)} 个活跃的Gmail对话") + if not service: + logger.error(f"获取Gmail服务失败: {user_email}") + return [] - # 方法1: 通过历史记录获取变更 - try: - logger.info(f"通过历史记录获取变更...") - # 获取历史记录变更,包含所有相关变更类型 - history_results = service.users().history().list( - userId='me', - startHistoryId=history_id, - historyTypes=['messageAdded', 'messageDeleted', 'labelAdded', 'labelRemoved'] - ).execute() - - # 保存最新的historyId(如果有) - if 'historyId' in history_results: - new_history_id = history_results['historyId'] - credential.last_history_id = new_history_id - credential.save() - logger.info(f"已更新最新历史ID: {new_history_id}") - - # 处理历史记录 - processed_by_history = False - if 'history' in history_results and history_results['history']: - logger.info(f"找到 {len(history_results.get('history', []))} 条历史变更记录") - - # 提取所有消息ID - message_ids = set() - for history in history_results.get('history', []): - # 检查不同类型的变更 - for messages_key in ['messagesAdded', 'labelAdded', 'labelRemoved']: - for message_item in history.get(messages_key, []): - if 'message' in message_item and 'id' in message_item['message']: - message_ids.add(message_item['message']['id']) - - if message_ids: - logger.info(f"从历史记录中找到 {len(message_ids)} 个消息ID") - processed_by_history = True - # 处理每个消息 - for message_id in message_ids: - GmailService._process_single_message(service, user, credential, message_id, active_conversations, influencer_emails) - else: - logger.info(f"未找到历史变更记录") - - # 方法2: 如果历史记录没有变更,直接查询收件箱最近邮件 - if not processed_by_history: - logger.info(f"未通过历史记录找到变更,尝试直接查询最近邮件...") - # 查询最近的10封邮件 - results = service.users().messages().list( - userId='me', - maxResults=10, - labelIds=['INBOX'] + logger.info(f"开始处理新邮件: 用户 {user.username}, 邮箱 {user_email}") + + # 获取历史记录(如果没有历史ID,则无法获取历史记录) + if not history_id and credential.last_history_id: + history_id = credential.last_history_id + logger.info(f"使用凭证中的历史ID: {history_id}") + + # 如果历史ID存在,则获取新变更;否则直接获取最新邮件 + new_messages = [] + + messages_found = False # 跟踪是否通过历史ID找到了新邮件 + + if history_id: + try: + history_list = service.users().history().list( + userId='me', + startHistoryId=history_id, + historyTypes=['messageAdded'] ).execute() - messages = results.get('messages', []) - if not messages: - logger.info(f"未找到任何收件箱邮件") - return + # 提取所有新增邮件ID + message_ids = [] - logger.info(f"找到 {len(messages)} 封收件箱邮件,检查最近的邮件") + # 处理历史变更 + if 'history' in history_list: + for history in history_list['history']: + if 'messagesAdded' in history: + for message_added in history['messagesAdded']: + message = message_added.get('message', {}) + msg_id = message.get('id') + if msg_id: + message_ids.append(msg_id) - # 检查所有邮件,不限制处理数量 - processed_message_ids = [] - saved_count = 0 + logger.info(f"从历史变更中获取到 {len(message_ids)} 封新邮件") - # 从conversation的metadata中获取已处理的消息ID - already_processed_ids = set() - for conversation in active_conversations: - if conversation.metadata and 'last_processed_messages' in conversation.metadata: - already_processed_ids.update(conversation.metadata.get('last_processed_messages', [])) - - for msg in messages: - message_id = msg['id'] - - # 避免重复处理 - if message_id in already_processed_ids: - logger.info(f"邮件ID: {message_id} 已处理过,跳过") - continue - - processed_message_ids.append(message_id) - logger.info(f"处理新发现的邮件ID: {message_id}") - - if GmailService._process_single_message(service, user, credential, message_id, active_conversations, influencer_emails): - saved_count += 1 - - # 更新最近处理的消息ID到所有活跃对话 - if processed_message_ids: - for conversation in active_conversations: - metadata = conversation.metadata or {} - - # 保留之前处理过的ID,加上新处理的ID - old_ids = metadata.get('last_processed_messages', []) - # 只保留最近的20个ID,避免列表过长 - new_ids = (processed_message_ids + old_ids)[:20] - - metadata['last_processed_messages'] = new_ids - conversation.metadata = metadata - conversation.save() - - logger.info(f"更新了 {len(processed_message_ids)} 个新处理的邮件ID,保存了 {saved_count} 封邮件") - - except HttpError as e: - if e.resp.status == 404: - # 历史ID可能无效,尝试获取当前ID并更新 - logger.warning(f"历史ID {history_id} 无效,尝试获取当前ID") - try: - profile = service.users().getProfile(userId='me').execute() - new_history_id = profile.get('historyId') - if new_history_id: - credential.last_history_id = new_history_id - credential.save() - logger.info(f"已更新为新的历史ID: {new_history_id}") - # 尝试使用方法2直接获取邮件 - logger.info(f"尝试直接获取最近邮件...") - GmailService.process_new_emails(user, credential, new_history_id) - except Exception as profile_error: - logger.error(f"获取新历史ID失败: {str(profile_error)}") - else: - logger.error(f"获取历史变更失败: {str(e)}") - - except Exception as e: - logger.error(f"处理Gmail新消息失败: {str(e)}") - # 记录堆栈跟踪以便更好地诊断问题 - import traceback - logger.error(f"错误详情: {traceback.format_exc()}") - - @staticmethod - def _process_single_message(service, user, credential, message_id, active_conversations, influencer_emails): - """处理单个邮件消息""" - try: - # 获取完整邮件内容 - message = service.users().messages().get(userId='me', id=message_id).execute() - email_data = GmailService._parse_email_content(message) - - if not email_data: - logger.warning(f"无法解析邮件内容: {message_id}") - return False - - logger.info(f"邮件信息: 发件人={email_data['from_email']}, 收件人={email_data['to_email']}, 主题={email_data['subject']}") - - saved = False - - # 场景1: 来自达人的邮件 - 寻找匹配的对话记录 - if email_data['from_email'] in influencer_emails: - logger.info(f"找到来自达人 {email_data['from_email']} 的邮件") - - # 查找相关对话 - conversation = active_conversations.filter( - influencer_email=email_data['from_email'] - ).first() - - if conversation: - logger.info(f"找到匹配的对话记录: ID={conversation.id}, 会话ID={conversation.conversation_id}") - - # 将新邮件保存到聊天历史 - success = GmailService._save_email_to_chat( - user, - credential, - conversation, - email_data - ) - - if success: - logger.info(f"成功保存邮件到聊天历史") - saved = True - - # 发送通知 + # 获取邮件完整内容 + for msg_id in message_ids: try: - NotificationService().send_notification( - user=user, - title="收到新邮件", - content=f"您收到来自 {email_data['from_email']} 的新邮件: {email_data['subject']}", - notification_type="gmail", - related_object_id=conversation.conversation_id - ) - except Exception as notif_error: - logger.error(f"发送通知失败: {str(notif_error)}") - else: - logger.error(f"保存邮件到聊天历史失败") - else: - # 找不到对话记录,创建新的 - logger.info(f"未找到与 {email_data['from_email']} 的对话记录,创建新对话") - try: - conversation_id = f"gmail_{user.id}_{str(uuid.uuid4())[:8]}" - conversation = GmailConversation.objects.create( - user=user, - user_email=credential.email, - influencer_email=email_data['from_email'], - conversation_id=conversation_id, - title=f"与 {email_data['from_email']} 的Gmail对话", - is_active=True - ) - - # 保存邮件到新创建的对话 - success = GmailService._save_email_to_chat( - user, - credential, - conversation, - email_data - ) - - if success: - logger.info(f"成功保存邮件到新创建的对话") - saved = True - except Exception as create_error: - logger.error(f"创建新对话失败: {str(create_error)}") - - # 场景2: 发送给达人的邮件 - 寻找匹配的对话记录 - elif email_data['to_email'] in influencer_emails: - logger.info(f"这是发送给达人 {email_data['to_email']} 的邮件") - - # 查找相关对话 - conversation = active_conversations.filter( - influencer_email=email_data['to_email'] - ).first() - - if conversation: - logger.info(f"找到匹配的对话记录: ID={conversation.id}, 会话ID={conversation.conversation_id}") + message = service.users().messages().get(userId='me', id=msg_id, format='full').execute() + new_messages.append(message) + messages_found = True # 标记找到了至少一封新邮件 + except Exception as e: + logger.error(f"获取邮件 {msg_id} 详情失败: {str(e)}") - # 将新邮件保存到聊天历史 - success = GmailService._save_email_to_chat( - user, - credential, - conversation, - email_data - ) + # 如果通过历史ID没有找到新邮件,直接获取最新邮件 + if not messages_found: + logger.info("通过历史ID没有找到新邮件,直接获取最新邮件...") + # 获取收件箱最新邮件 + results = service.users().messages().list(userId='me', labelIds=['INBOX'], maxResults=10).execute() + messages = results.get('messages', []) + + # 获取每封邮件的详细信息 + for message in messages: + try: + msg = service.users().messages().get(userId='me', id=message['id'], format='full').execute() + new_messages.append(msg) + except Exception as e: + logger.error(f"获取邮件 {message['id']} 详情失败: {str(e)}") + + except Exception as he: + logger.error(f"获取历史变更失败: {str(he)}") + # 历史获取失败,退回到获取最新邮件 + logger.info("尝试直接获取最新邮件...") + + # 获取收件箱最新邮件 + results = service.users().messages().list(userId='me', labelIds=['INBOX'], maxResults=10).execute() + messages = results.get('messages', []) + + # 获取每封邮件的详细信息 + for message in messages: + try: + msg = service.users().messages().get(userId='me', id=message['id'], format='full').execute() + new_messages.append(msg) + except Exception as e: + logger.error(f"获取邮件 {message['id']} 详情失败: {str(e)}") + else: + # 没有历史ID,直接获取最新邮件 + logger.info("没有历史ID,直接获取最新邮件...") + + # 获取收件箱最新邮件 + results = service.users().messages().list(userId='me', labelIds=['INBOX'], maxResults=10).execute() + messages = results.get('messages', []) + + # 获取每封邮件的详细信息 + for message in messages: + try: + msg = service.users().messages().get(userId='me', id=message['id'], format='full').execute() + new_messages.append(msg) + except Exception as e: + logger.error(f"获取邮件 {message['id']} 详情失败: {str(e)}") + + # 获取已有的邮件ID,避免重复处理 + from apps.chat.models import ChatHistory + + # 查询数据库中已经存在的Gmail消息ID + existing_message_ids = set() + for chat in ChatHistory.objects.filter( + metadata__has_key='gmail_message_id' + ).values_list('metadata', flat=True): + if chat and 'gmail_message_id' in chat: + existing_message_ids.add(chat['gmail_message_id']) + + logger.info(f"数据库中已存在 {len(existing_message_ids)} 封邮件记录") + logger.info(f"获取到 {len(new_messages)} 封新邮件,开始处理") + + # 处理每封新邮件 + processed_ids = [] + for message in new_messages: + try: + # 提取邮件基本信息 + msg_id = message.get('id') + + # 检查邮件是否已处理 + if msg_id in existing_message_ids: + logger.info(f"邮件 {msg_id} 已存在于数据库中,跳过处理") + continue + + # 使用单独的方法处理单个邮件 + success = GmailService._process_single_email(user, credential, message, msg_id, user_email) if success: - logger.info(f"成功保存邮件到聊天历史") - saved = True + processed_ids.append(msg_id) + logger.info(f"成功处理邮件: {msg_id}") else: - logger.error(f"保存邮件到聊天历史失败") - else: - # 找不到对话记录,创建新的 - logger.info(f"未找到与 {email_data['to_email']} 的对话记录,创建新对话") - try: - conversation_id = f"gmail_{user.id}_{str(uuid.uuid4())[:8]}" - conversation = GmailConversation.objects.create( - user=user, - user_email=credential.email, - influencer_email=email_data['to_email'], - conversation_id=conversation_id, - title=f"与 {email_data['to_email']} 的Gmail对话", - is_active=True - ) - - # 保存邮件到新创建的对话 - success = GmailService._save_email_to_chat( - user, - credential, - conversation, - email_data - ) - - if success: - logger.info(f"成功保存邮件到新创建的对话") - saved = True - except Exception as create_error: - logger.error(f"创建新对话失败: {str(create_error)}") + logger.warning(f"邮件处理失败或未满足处理条件: {msg_id}") + except Exception as msg_error: + logger.error(f"处理邮件失败: {str(msg_error)}") + import traceback + logger.error(traceback.format_exc()) - # 场景3: 其他邮件 - 不保存非达人相关邮件 - else: - logger.info(f"邮件 {email_data['from_email']} → {email_data['to_email']} 与跟踪的达人对话无关,不保存") - - return saved + logger.info(f"成功处理 {len(processed_ids)} 封新邮件") + return processed_ids except Exception as e: - logger.error(f"处理邮件 {message_id} 时出错: {str(e)}") + logger.error(f"处理新邮件失败: {str(e)}") import traceback - logger.error(f"错误详情: {traceback.format_exc()}") + logger.error(traceback.format_exc()) + return [] + + @staticmethod + def _process_single_email(user, credential, message, msg_id, user_email): + """ + 处理单个邮件,提取信息并保存到对话历史 + + Args: + user: 用户对象 + credential: Gmail凭证 + message: 邮件对象 + msg_id: 邮件ID + user_email: 用户邮箱 + + Returns: + bool: 处理是否成功 + """ + try: + # 解析邮件头部 + headers = message.get('payload', {}).get('headers', []) + + subject = '' + from_email = '' + to_email = '' + date_str = '' + + for header in headers: + if header.get('name') == 'Subject': + subject = header.get('value', '') + elif header.get('name') == 'From': + from_email = GmailService.extract_email(header.get('value', '')) + elif header.get('name') == 'To': + to_email = GmailService.extract_email(header.get('value', '')) + elif header.get('name') == 'Date': + date_str = header.get('value', '') + + logger.info(f"处理邮件详情: ID={msg_id}, 主题='{subject}', 发件人={from_email}, 收件人={to_email}, 日期={date_str}") + + # 邮件内部日期 + internal_date = message.get('internalDate') + if internal_date: + timestamp_ms = int(internal_date) + dt = datetime.datetime.fromtimestamp(timestamp_ms / 1000.0) + logger.info(f"邮件内部时间戳: {dt.strftime('%Y-%m-%d %H:%M:%S')}") + + # 提取邮件内容 + body = GmailService.get_email_body(message) + logger.info(f"邮件内容长度: {len(body)} 字符") + + # 检查是否是用户收到的邮件(即达人->用户) + if to_email == user_email: + # 查找相关的对话 + from apps.gmail.models import GmailConversation + + conversations = GmailConversation.objects.filter( + user=user, + user_email=to_email, + influencer_email=from_email + ) + + if conversations.exists(): + # 如果找到对话,保存邮件到对话历史 + conversation = conversations.first() + logger.info(f"找到匹配的对话: ID={conversation.conversation_id}, 用户邮箱={to_email}, 达人邮箱={from_email}") + + # 尝试将邮件关联到对话并保存 + GmailService._save_email_to_chat(user, credential, conversation, { + 'id': msg_id, + 'from': f"{from_email}", + 'from_email': from_email, + 'to': f"{to_email}", + 'to_email': to_email, + 'subject': subject, + 'body': body, + 'date': timezone.now().strftime('%Y-%m-%d %H:%M:%S'), + 'attachments': [] # 这里可以添加附件处理逻辑 + }) + + logger.info(f"已将邮件保存到对话历史: {msg_id}") + return True + else: + logger.info(f"未找到匹配的对话: 用户邮箱={to_email}, 达人邮箱={from_email}") + else: + logger.info(f"邮件不是发送给用户的: 发件人={from_email}, 收件人={to_email}, 用户邮箱={user_email}") + + return False + + except Exception as e: + logger.error(f"处理单个邮件失败: {str(e)}") + import traceback + logger.error(traceback.format_exc()) return False @staticmethod @@ -1063,87 +894,89 @@ class GmailService: conversation.last_sync_time = timezone.now() conversation.save() - # 保存到聊天历史 - if conversation: - # 获取知识库 - knowledge_base = KnowledgeBase.objects.filter(user_id=user.id, type='private').first() - if not knowledge_base: - logger.warning(f"未找到默认知识库,邮件发送成功但未保存到聊天记录") - else: - # 创建聊天消息 - chat_message = ChatHistory.objects.create( - user=user, - knowledge_base=knowledge_base, - conversation_id=conversation.conversation_id, - title=conversation.title, - role='user', - content=f"主题: {subject}\n\n{body}", - metadata={ - 'gmail_message_id': message_id, - 'from': user_email, - 'to': to_email, - 'date': timezone.now().strftime('%Y-%m-%d %H:%M:%S'), - 'source': 'gmail' - } - ) - - # 如果有附件,保存附件信息 - if attachments and isinstance(attachments, list): - metadata = chat_message.metadata or {} - if 'attachments' not in metadata: - metadata['attachments'] = [] + # 获取知识库 + knowledge_base = KnowledgeBase.objects.filter(user_id=user.id, type='private').first() + if not knowledge_base: + logger.warning(f"未找到默认知识库,邮件发送成功但未保存到聊天记录") + else: + # 创建聊天消息 - 直接使用邮件正文,不添加主题前缀 + content = body + + # 创建聊天消息 + chat_message = ChatHistory.objects.create( + user=user, + knowledge_base=knowledge_base, + conversation_id=conversation.conversation_id, + title=conversation.title, + role='user', + content=content, + metadata={ + 'gmail_message_id': message_id, + 'from': user_email, + 'to': to_email, + 'date': timezone.now().strftime('%Y-%m-%d %H:%M:%S'), + 'subject': subject, # 将主题保存在metadata中 + 'source': 'gmail' + } + ) + + # 如果有附件,保存附件信息 + if attachments and isinstance(attachments, list): + metadata = chat_message.metadata or {} + if 'attachments' not in metadata: + metadata['attachments'] = [] + + for attachment in attachments: + if 'path' not in attachment: + continue + + filepath = attachment['path'] + filename = attachment.get('filename', os.path.basename(filepath)) + + if not os.path.exists(filepath): + continue + + # 复制附件到Gmail附件目录 + try: + # 确保目录存在 + os.makedirs(GmailService.ATTACHMENT_DIR, exist_ok=True) - for attachment in attachments: - if 'path' not in attachment: - continue + # 生成唯一文件名 + unique_filename = f"{uuid.uuid4()}_{filename}" + target_path = os.path.join(GmailService.ATTACHMENT_DIR, unique_filename) - filepath = attachment['path'] - filename = attachment.get('filename', os.path.basename(filepath)) + # 复制文件 + shutil.copy2(filepath, target_path) - if not os.path.exists(filepath): - continue + # 获取文件大小和类型 + filesize = os.path.getsize(filepath) + content_type, _ = mimetypes.guess_type(filepath) + if content_type is None: + content_type = 'application/octet-stream' - # 复制附件到Gmail附件目录 - try: - # 确保目录存在 - os.makedirs(GmailService.ATTACHMENT_DIR, exist_ok=True) - - # 生成唯一文件名 - unique_filename = f"{uuid.uuid4()}_{filename}" - target_path = os.path.join(GmailService.ATTACHMENT_DIR, unique_filename) - - # 复制文件 - shutil.copy2(filepath, target_path) - - # 获取文件大小和类型 - filesize = os.path.getsize(filepath) - content_type, _ = mimetypes.guess_type(filepath) - if content_type is None: - content_type = 'application/octet-stream' - - # 创建附件记录 - gmail_attachment = GmailAttachment.objects.create( - conversation=conversation, - email_message_id=message_id, - attachment_id=f"outgoing_{uuid.uuid4()}", - filename=filename, - file_path=target_path, - content_type=content_type, - size=filesize, - sender_email=user_email, - chat_message_id=str(chat_message.id) - ) - - # 更新聊天消息,添加附件信息 - metadata['attachments'].append({ - 'id': str(gmail_attachment.id), - 'filename': filename, - 'size': filesize, - 'mime_type': content_type, - 'url': gmail_attachment.get_absolute_url() - }) - except Exception as e: - logger.error(f"处理发送邮件附件时出错: {str(e)}") + # 创建附件记录 + gmail_attachment = GmailAttachment.objects.create( + conversation=conversation, + email_message_id=message_id, + attachment_id=f"outgoing_{uuid.uuid4()}", + filename=filename, + file_path=target_path, + content_type=content_type, + size=filesize, + sender_email=user_email, + chat_message_id=str(chat_message.id) + ) + + # 更新聊天消息,添加附件信息 + metadata['attachments'].append({ + 'id': str(gmail_attachment.id), + 'filename': filename, + 'size': filesize, + 'mime_type': content_type, + 'url': gmail_attachment.get_absolute_url() + }) + except Exception as e: + logger.error(f"处理发送邮件附件时出错: {str(e)}") # 保存更新的元数据 if metadata['attachments']: @@ -1191,8 +1024,12 @@ class GmailService: is_from_user = email_data['from_email'].lower() == credential.email.lower() role = 'user' if is_from_user else 'assistant' - # 准备内容文本 - content = f"主题: {email_data['subject']}\n\n{email_data['body']}" + # 记录角色判断信息,以便调试 + logger.info(f"邮件角色判断: 发件人={email_data['from_email']}, 用户邮箱={credential.email}, 判定为{'用户' if is_from_user else '达人'}, 角色={role}") + logger.info(f"邮件详情: 主题='{email_data['subject']}', 发送时间={email_data['date']}") + + # 直接使用原始邮件内容,不进行任何清理或修改 + content = email_data['body'] # 创建聊天消息 chat_message = ChatHistory.objects.create( @@ -1207,10 +1044,14 @@ class GmailService: 'from': email_data['from'], 'to': email_data['to'], 'date': email_data['date'], + 'subject': email_data['subject'], # 将主题保存在metadata中 'source': 'gmail' } ) + # 记录保存的消息ID和角色 + logger.info(f"已保存邮件到聊天历史: ID={chat_message.id}, 角色={role}, conversation_id={conversation.conversation_id}") + # 更新对话的同步时间 conversation.last_sync_time = timezone.now() conversation.save() @@ -1351,5 +1192,267 @@ class GmailService: logger.error(traceback.format_exc()) return None, error_msg + @staticmethod + def _safe_filename(filename): + """ + 确保文件名安全,移除不安全字符 + + Args: + filename: 原始文件名 + + Returns: + str: 安全的文件名 + """ + # 替换不安全字符 + unsafe_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|'] + safe_name = filename + for char in unsafe_chars: + safe_name = safe_name.replace(char, '_') + + # 限制文件名长度 + if len(safe_name) > 200: + name, ext = os.path.splitext(safe_name) + safe_name = name[:196] + ext if ext else name[:200] + + return safe_name + + @staticmethod + def _parse_email_content(message): + """ + 解析Gmail API返回的邮件内容 + + Args: + message: Gmail API返回的邮件对象 + + Returns: + dict: 包含邮件各部分内容的字典 + """ + try: + # 提取邮件标识符 + message_id = message['id'] + thread_id = message['threadId'] + + # 提取邮件头部信息 + headers = {} + for header in message['payload']['headers']: + headers[header['name'].lower()] = header['value'] + + # 提取发件人和收件人信息 + from_header = headers.get('from', '') + to_header = headers.get('to', '') + subject = headers.get('subject', '(无主题)') + + # 解析发件人和收件人邮箱 + from_name, from_email = parseaddr(from_header) + to_name, to_email = parseaddr(to_header) + + # 提取邮件日期 + date_str = headers.get('date', '') + # 尝试解析日期,无法解析则使用当前时间 + try: + parsed_date = email.utils.parsedate_to_datetime(date_str) + date = parsed_date.strftime('%Y-%m-%d %H:%M:%S') + except: + date = timezone.now().strftime('%Y-%m-%d %H:%M:%S') + + # 解析邮件内容 + body_text, body_html, attachments = GmailService._parse_message_parts(message) + + # 使用纯文本内容,如果没有则使用从HTML提取的文本 + body = body_text if body_text else GmailService._html_to_text(body_html) if body_html else "" + + # 构建返回结果 + result = { + 'id': message_id, + 'thread_id': thread_id, + 'from': from_header, + 'from_email': from_email, + 'to': to_header, + 'to_email': to_email, + 'subject': subject, + 'date': date, + 'body': body, + 'body_html': body_html, + 'attachments': attachments + } + + return result + except Exception as e: + logger.error(f"解析邮件内容失败: {str(e)}") + logger.error(traceback.format_exc()) + return None + + @staticmethod + def _parse_message_parts(message): + """ + 递归解析邮件部分,提取文本、HTML和附件 + + Args: + message: Gmail API返回的邮件对象 + + Returns: + tuple: (纯文本内容, HTML内容, 附件列表) + """ + body_text = "" + body_html = "" + attachments = [] + + if 'payload' not in message: + return body_text, body_html, attachments + + # 检查是否有单个部分 + if 'body' in message['payload']: + data = message['payload']['body'].get('data', '') + if data: + decoded_data = base64.urlsafe_b64decode(data).decode('utf-8', errors='replace') + mime_type = message['payload'].get('mimeType', '') + if mime_type == 'text/plain': + body_text = decoded_data + elif mime_type == 'text/html': + body_html = decoded_data + + # 检查是否是附件 + if message['payload'].get('filename') and message['payload']['body'].get('attachmentId'): + attachments.append({ + 'filename': message['payload']['filename'], + 'mimeType': message['payload'].get('mimeType', 'application/octet-stream'), + 'size': message['payload']['body'].get('size', 0), + 'attachmentId': message['payload']['body']['attachmentId'] + }) + + # 处理多部分邮件 + if 'parts' in message['payload']: + for part in message['payload']['parts']: + # 递归处理每个部分 + part_text, part_html, part_attachments = GmailService._process_message_part(part) + body_text += part_text + body_html += part_html + attachments.extend(part_attachments) + + return body_text, body_html, attachments + + @staticmethod + def _process_message_part(part): + """ + 处理邮件的单个部分 + + Args: + part: 邮件部分 + + Returns: + tuple: (纯文本内容, HTML内容, 附件列表) + """ + body_text = "" + body_html = "" + attachments = [] + + # 获取MIME类型 + mime_type = part.get('mimeType', '') + + # 处理文本内容 + if 'body' in part and 'data' in part['body']: + data = part['body']['data'] + if data: + decoded_data = base64.urlsafe_b64decode(data).decode('utf-8', errors='replace') + if mime_type == 'text/plain': + body_text = decoded_data + elif mime_type == 'text/html': + body_html = decoded_data + + # 处理附件 + if part.get('filename') and 'body' in part and 'attachmentId' in part['body']: + attachments.append({ + 'filename': part['filename'], + 'mimeType': mime_type, + 'size': part['body'].get('size', 0), + 'attachmentId': part['body']['attachmentId'] + }) + + # 处理嵌套的multipart部分 + if mime_type.startswith('multipart/') and 'parts' in part: + for subpart in part['parts']: + sub_text, sub_html, sub_attachments = GmailService._process_message_part(subpart) + body_text += sub_text + body_html += sub_html + attachments.extend(sub_attachments) + + return body_text, body_html, attachments + + @staticmethod + def _html_to_text(html): + """ + 从HTML内容中提取纯文本 + + Args: + html: HTML内容 + + Returns: + str: 提取的纯文本 + """ + if not html: + return "" + + # 简单地移除HTML标签 + import re + text = re.sub(r'<[^>]+>', ' ', html) + text = re.sub(r'\s+', ' ', text) + return text.strip() + + @staticmethod + def extract_email(header_value): + """ + 从邮件头值中提取电子邮件地址 + + Args: + header_value: 邮件头值,格式可能是"Name "或纯"email@domain.com" + + Returns: + str: 提取出的电子邮件地址 + """ + try: + if not header_value: + return "" + + # 使用email.utils提供的解析函数 + name, email = parseaddr(header_value) + return email.lower() + except Exception as e: + logger.error(f"提取邮件地址失败: {str(e)}") + return "" + + @staticmethod + def get_email_body(message): + """ + 从Gmail消息中提取邮件正文 + + Args: + message: Gmail API返回的邮件对象 + + Returns: + str: 邮件正文内容 + """ + try: + # 优先使用解析消息部分的方法获取邮件内容 + body_text, body_html, _ = GmailService._parse_message_parts(message) + + # 如果存在纯文本内容,优先使用 + if body_text: + return body_text + + # 否则从HTML中提取文本 + if body_html: + return GmailService._html_to_text(body_html) + + # 如果以上都不存在,尝试直接获取payload中的body + if 'payload' in message and 'body' in message['payload'] and 'data' in message['payload']['body']: + data = message['payload']['body']['data'] + text = base64.urlsafe_b64decode(data).decode('utf-8', errors='replace') + return text + + return "" + except Exception as e: + logger.error(f"提取邮件正文失败: {str(e)}") + return "" + \ No newline at end of file diff --git a/apps/gmail/services/goal_service.py b/apps/gmail/services/goal_service.py new file mode 100644 index 0000000..6804754 --- /dev/null +++ b/apps/gmail/services/goal_service.py @@ -0,0 +1,217 @@ +import logging +from django.conf import settings +from datetime import datetime +from apps.gmail.models import GmailConversation, ConversationSummary, UserGoal +from apps.chat.models import ChatHistory +from apps.common.services.ai_service import AIService +import traceback +from apps.accounts.services.utils import validate_uuid_param +from apps.gmail.services.gmail_service import GmailService + +logger = logging.getLogger(__name__) + +def get_conversation(conversation_id): + """ + 获取给定对话ID的对话内容 + + 参数: + - conversation_id: 对话ID + + 返回: + - 对话数据,包含消息列表,如无法获取则返回None + """ + try: + # 验证对话ID参数 + uuid_obj, error = validate_uuid_param(conversation_id) + if error: + logger.error(f"无效的对话ID: {conversation_id}") + return None + + # 查找对话 + conversation = GmailConversation.objects.filter(conversation_id=conversation_id).first() + if not conversation: + logger.warning(f"未找到对话记录: {conversation_id}") + return None + + # 获取对话消息 + messages = ChatHistory.objects.filter( + conversation_id=conversation_id + ).order_by('created_at') + + if not messages: + logger.warning(f"对话 {conversation_id} 没有消息记录") + return None + + # 构造消息列表 + message_list = [] + for msg in messages: + message_list.append({ + 'id': str(msg.id), + 'content': msg.content, + 'is_user': msg.role == 'user', + 'timestamp': msg.created_at.isoformat(), + 'metadata': msg.metadata or {} + }) + + return { + 'id': conversation_id, + 'title': conversation.title, + 'user_email': conversation.user_email, + 'influencer_email': conversation.influencer_email, + 'messages': message_list + } + + except Exception as e: + logger.error(f"获取对话时发生错误: {str(e)}") + logger.error(traceback.format_exc()) + return None + +def get_conversation_summary(conversation_id): + """ + 获取对话摘要 + + Args: + conversation_id: 对话ID + + Returns: + str: 摘要内容或None + """ + try: + # 先检查持久化存储的摘要 + try: + # 不使用UUID验证,直接通过conversation_id查找 + conversation = GmailConversation.objects.filter(conversation_id=conversation_id).first() + if not conversation: + logger.warning(f"未找到对话: {conversation_id}") + return None + + summary = ConversationSummary.objects.filter(conversation=conversation).first() + if summary: + return summary.content + except Exception as e: + logger.error(f"获取持久化摘要失败: {str(e)}") + # 继续尝试生成简单摘要 + + # 如果没有持久化的摘要,尝试生成简单摘要 + chat_history = ChatHistory.objects.filter(conversation_id=conversation_id).order_by('-created_at')[:5] + if not chat_history: + return None + + # 生成简单摘要(最近几条消息) + messages = [] + for msg in chat_history: + if len(messages) < 3: # 只取最新的3条 + role = "用户" if msg.role == "user" else "达人" + content = msg.content + if len(content) > 100: + content = content[:100] + "..." + messages.append(f"{role}: {content}") + + if messages: + return "最近对话: " + " | ".join(reversed(messages)) + return None + except Exception as e: + logger.error(f"获取对话摘要失败: {str(e)}") + return None + +def get_last_message(conversation_id): + """ + 获取给定对话的最后一条消息 + + 参数: + - conversation_id: 对话ID + + 返回: + - 最后一条消息文本,如果无法获取则返回None + """ + try: + # 直接通过conversation_id获取最后一条消息,不进行UUID验证 + # 获取对话消息 + chat_messages = ChatHistory.objects.filter( + conversation_id=conversation_id + ).order_by('created_at') + + if not chat_messages: + logger.warning(f"对话 {conversation_id} 没有消息记录") + return None + + # 过滤出达人发送的消息 + influencer_messages = [msg for msg in chat_messages if msg.role == 'assistant'] + + if not influencer_messages: + logger.warning(f"对话 {conversation_id} 中没有达人发送的消息") + return None + + # 返回最后一条达人消息 + last_message = influencer_messages[-1].content + return last_message + + except Exception as e: + logger.error(f"获取最后一条消息时发生错误: {str(e)}") + logger.error(traceback.format_exc()) + return None + +def generate_recommended_reply(user, goal_description, conversation_summary, last_message): + """ + 根据用户目标、对话摘要和最后一条消息生成推荐话术 + + Args: + user: 用户对象 + goal_description: 用户目标描述 + conversation_summary: 对话摘要 + last_message: 达人最后发送的消息内容 + + Returns: + tuple: (推荐话术内容, 错误信息) + """ + # 直接调用AIService生成回复 + return AIService.generate_email_reply(goal_description, conversation_summary, last_message) + +def get_or_create_goal(user, conversation_id, goal_description=None): + """ + 获取或创建用户目标 + + Args: + user: 用户对象 + conversation_id: 对话ID + goal_description: 目标描述 (可选,如不提供则只获取不创建) + + Returns: + tuple: (UserGoal, bool) - 目标对象和是否新创建的标志 + """ + # 查找对话 + conversation = GmailConversation.objects.filter( + conversation_id=conversation_id, + user=user + ).first() + + if not conversation: + return None, False + + # 查找现有目标 + goal = UserGoal.objects.filter( + user=user, + conversation=conversation + ).first() + + # 如果有目标且不需要更新,直接返回 + if goal and not goal_description: + return goal, False + + # 如果有目标需要更新 + if goal and goal_description: + goal.description = goal_description + goal.save() + return goal, False + + # 如果需要创建新目标 + if not goal and goal_description: + goal = UserGoal.objects.create( + user=user, + conversation=conversation, + description=goal_description + ) + return goal, True + + return None, False + diff --git a/apps/gmail/urls.py b/apps/gmail/urls.py index 6c04318..3c1047a 100644 --- a/apps/gmail/urls.py +++ b/apps/gmail/urls.py @@ -1,24 +1,29 @@ -from django.urls import path +from django.urls import path, include +from rest_framework.routers import DefaultRouter from .views import ( GmailAuthInitiateView, GmailAuthCompleteView, - GmailCredentialListView, - GmailCredentialDetailView, GmailConversationView, GmailAttachmentListView, GmailPubSubView, GmailSendEmailView, GmailWebhookView, - GmailConversationSummaryView + GmailConversationSummaryView, + GmailGoalView, + SimpleRecommendedReplyView, + GmailCredentialViewSet ) app_name = 'gmail' +# 创建Router并注册ViewSet +router = DefaultRouter() +router.register(r'credentials', GmailCredentialViewSet, basename='credential') + +# 基本URL模式 urlpatterns = [ path('auth/initiate/', GmailAuthInitiateView.as_view(), name='auth_initiate'), path('auth/complete/', GmailAuthCompleteView.as_view(), name='auth_complete'), - path('credentials/', GmailCredentialListView.as_view(), name='credential_list'), - path('credentials//', GmailCredentialDetailView.as_view(), name='credential_detail'), path('conversations/', GmailConversationView.as_view(), name='conversation_list'), path('attachments/', GmailAttachmentListView.as_view(), name='attachment_list'), path('attachments//', GmailAttachmentListView.as_view(), name='attachment_list_by_conversation'), @@ -27,4 +32,8 @@ urlpatterns = [ path('webhook/', GmailWebhookView.as_view(), name='webhook'), path('conversations/summary/', GmailConversationSummaryView.as_view(), name='conversation_summary_list'), path('conversations/summary//', GmailConversationSummaryView.as_view(), name='conversation_summary_detail'), + path('goals/', GmailGoalView.as_view(), name='user_goals'), + path('recommended-reply/', SimpleRecommendedReplyView.as_view(), name='recommended_reply'), + # 包含Router生成的URL + path('', include(router.urls)), ] \ No newline at end of file diff --git a/apps/gmail/views.py b/apps/gmail/views.py index add1074..66ebb98 100644 --- a/apps/gmail/views.py +++ b/apps/gmail/views.py @@ -1,10 +1,15 @@ +import traceback from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated -from rest_framework import status -from .serializers import GmailCredentialSerializer +from rest_framework import status, viewsets +from rest_framework.decorators import action + +from apps.accounts.services.utils import validate_uuid_param +from apps.gmail.services.goal_service import generate_recommended_reply, get_conversation_summary, get_last_message +from .serializers import GmailCredentialSerializer, UserGoalSerializer, AutoReplyConfigSerializer from .services.gmail_service import GmailService -from .models import GmailCredential, GmailConversation, GmailAttachment +from .models import GmailCredential, GmailConversation, GmailAttachment, UserGoal, ConversationSummary from django.shortcuts import get_object_or_404 import logging import os @@ -14,6 +19,13 @@ from django.core.files.base import ContentFile import json import base64 import threading +from apps.feishu.services.auto_gmail_conversation_service import AutoGmailConversationService +from django.utils import timezone +from django.http import HttpResponse, Http404 +from django.db.models import Q, Prefetch +import uuid +from apps.common.services.ai_service import AIService +from apps.chat.models import ChatHistory # 配置日志记录器,用于记录视图操作的调试、警告和错误信息 logger = logging.getLogger(__name__) @@ -125,125 +137,233 @@ class GmailAuthCompleteView(APIView): }, status=status.HTTP_400_BAD_REQUEST) -class GmailCredentialListView(APIView): +class GmailCredentialViewSet(viewsets.ModelViewSet): """ - API 视图,用于列出用户的所有 Gmail 凭证。 + Gmail凭证管理视图集,提供对Gmail账户凭证的完整CRUD操作 + + 此视图集替代了旧的GmailCredentialListView和GmailCredentialDetailView, + 提供了更完整的功能和更统一的API接口。 + + 支持以下操作: + - list: 获取凭证列表 + - create: 创建新凭证(初始化OAuth或完成OAuth) + - retrieve: 获取单个凭证详情 + - update: 完全更新凭证 + - partial_update: 部分更新凭证 + - destroy: 删除凭证 + - set_default: 设置默认凭证(自定义操作) """ - permission_classes = [IsAuthenticated] # 限制访问,仅允许已认证用户 - - def get(self, request): + permission_classes = [IsAuthenticated] + serializer_class = GmailCredentialSerializer + + def get_queryset(self): + """获取当前用户的Gmail凭证""" + return GmailCredential.objects.filter(user=self.request.user) + + def perform_create(self, serializer): + """创建凭证时自动关联当前用户""" + serializer.save(user=self.request.user) + + def list(self, request, *args, **kwargs): """ - 处理 GET 请求,返回用户的所有 Gmail 凭证列表。 - - Args: - request: Django REST Framework 请求对象。 - - Returns: - Response: 包含凭证列表的 JSON 响应。 - - Status Codes: - 200: 成功返回凭证列表。 + 列出用户的Gmail账号凭证 + + 返回当前用户所有Gmail账号凭证的列表。 """ - # 获取用户关联的所有 Gmail 凭证 - credentials = request.user.gmail_credentials.all() - # 序列化凭证数据 - serializer = GmailCredentialSerializer(credentials, many=True, context={'request': request}) + queryset = self.get_queryset() + serializer = self.get_serializer(queryset, many=True) return Response({ 'code': 200, - 'message': '成功获取凭证列表', + 'message': '获取Gmail账号列表成功', 'data': serializer.data - }, status=status.HTTP_200_OK) - - -class GmailCredentialDetailView(APIView): - """ - API 视图,用于管理特定 Gmail 凭证的获取、更新和删除。 - """ - permission_classes = [IsAuthenticated] # 限制访问,仅允许已认证用户 - - def get(self, request, pk): + }) + + def retrieve(self, request, *args, **kwargs): """ - 处理 GET 请求,获取特定 Gmail 凭证的详细信息。 - - Args: - request: Django REST Framework 请求对象。 - pk: 凭证的主键 ID。 - - Returns: - Response: 包含凭证详细信息的 JSON 响应。 - - Status Codes: - 200: 成功返回凭证信息。 - 404: 未找到指定凭证。 + 获取特定Gmail凭证的详细信息 + + 根据ID返回单个Gmail账号凭证的详细信息。 """ - # 获取用户拥有的指定凭证,未找到则返回 404 - credential = get_object_or_404(GmailCredential, pk=pk, user=request.user) - serializer = GmailCredentialSerializer(credential, context={'request': request}) + instance = self.get_object() + serializer = self.get_serializer(instance) return Response({ 'code': 200, 'message': '成功获取凭证详情', 'data': serializer.data - }, status=status.HTTP_200_OK) - - def patch(self, request, pk): + }) + + def create(self, request, *args, **kwargs): """ - 处理 PATCH 请求,更新特定 Gmail 凭证(如设置为默认凭证)。 - - Args: - request: Django REST Framework 请求对象,包含更新数据。 - pk: 凭证的主键 ID。 - - Returns: - Response: 包含更新后凭证数据的 JSON 响应,或错误信息。 - - Status Codes: - 200: 成功更新凭证。 - 400: 请求数据无效。 - 404: 未找到指定凭证。 + 创建Gmail账号凭证 - 初始化或完成OAuth授权 + + 此接口有两个功能: + 1. 如果未提供auth_code,则初始化OAuth并返回授权URL + 2. 如果提供了auth_code,则完成OAuth并保存凭证 + + 请求参数: + - client_secret_json: Google Cloud项目的客户端密钥 + - auth_code: [可选] 授权码,用于完成OAuth流程 """ - # 获取用户拥有的指定凭证 - credential = get_object_or_404(GmailCredential, pk=pk, user=request.user) - serializer = GmailCredentialSerializer(credential, data=request.data, partial=True, context={'request': request}) - if serializer.is_valid(): + try: + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + auth_code = serializer.validated_data.get('auth_code') + client_secret_json = serializer.validated_data.get('client_secret_json') + + if not auth_code: # 初始化OAuth + auth_url = GmailService.start_oauth(client_secret_json) + return Response({ + 'code': 200, + 'message': '授权URL生成成功', + 'data': {'auth_url': auth_url} + }) + else: # 完成OAuth + email, credentials = GmailService.complete_oauth(client_secret_json, auth_code) + if not email: + return Response({ + 'code': 400, + 'message': f'授权失败: {credentials}', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 保存凭证 + serializer.save( + user=request.user, + email=email, + credentials=credentials, + is_valid=True + ) + + return Response({ + 'code': 201, + 'message': '授权成功', + 'data': { + 'id': serializer.instance.id, + 'email': email, + 'is_valid': True + } + }, status=status.HTTP_201_CREATED) + + except Exception as e: + logger.error(f"创建Gmail凭证失败: {str(e)}") + logger.error(traceback.format_exc()) + return Response({ + 'code': 500, + 'message': f'创建Gmail凭证失败: {str(e)}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + def update(self, request, *args, **kwargs): + """ + 完全更新Gmail凭证信息 + + 更新Gmail凭证的所有可编辑字段。 + 如果设置is_default=True,会自动将其他凭证设为非默认。 + """ + try: + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data) + serializer.is_valid(raise_exception=True) + # 如果设置为默认凭证,清除其他凭证的默认状态 if serializer.validated_data.get('is_default', False): - GmailCredential.objects.filter(user=request.user).exclude(id=credential.id).update(is_default=False) + GmailCredential.objects.filter(user=request.user).exclude(id=instance.id).update(is_default=False) + serializer.save() return Response({ 'code': 200, 'message': '成功更新凭证', 'data': serializer.data - }, status=status.HTTP_200_OK) - # 返回无效数据错误 - return Response({ - 'code': 400, - 'message': '请求数据无效', - 'data': serializer.errors - }, status=status.HTTP_400_BAD_REQUEST) - - def delete(self, request, pk): + }) + except Exception as e: + logger.error(f"更新Gmail凭证失败: {str(e)}") + return Response({ + 'code': 500, + 'message': f'更新Gmail凭证失败: {str(e)}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + def partial_update(self, request, *args, **kwargs): """ - 处理 DELETE 请求,删除特定 Gmail 凭证。 - - Args: - request: Django REST Framework 请求对象。 - pk: 凭证的主键 ID。 - - Returns: - Response: 空响应,表示删除成功。 - - Status Codes: - 204: 成功删除凭证。 - 404: 未找到指定凭证。 + 部分更新Gmail凭证信息 + + 更新Gmail凭证的部分字段。 + 如果设置is_default=True,会自动将其他凭证设为非默认。 """ - # 获取并删除用户拥有的指定凭证 - credential = get_object_or_404(GmailCredential, pk=pk, user=request.user) - credential.delete() - return Response({ - 'code': 204, - 'message': '凭证已成功删除', - 'data': None - }, status=status.HTTP_204_NO_CONTENT) + try: + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + + # 如果设置为默认凭证,清除其他凭证的默认状态 + if serializer.validated_data.get('is_default', False): + GmailCredential.objects.filter(user=request.user).exclude(id=instance.id).update(is_default=False) + + serializer.save() + return Response({ + 'code': 200, + 'message': '成功更新凭证', + 'data': serializer.data + }) + except Exception as e: + logger.error(f"更新Gmail凭证失败: {str(e)}") + return Response({ + 'code': 500, + 'message': f'更新Gmail凭证失败: {str(e)}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + def destroy(self, request, *args, **kwargs): + """ + 删除Gmail账号凭证 + + 根据ID删除指定的Gmail凭证。 + """ + try: + instance = self.get_object() + self.perform_destroy(instance) + return Response({ + 'code': 200, + 'message': '删除Gmail账号成功', + 'data': None + }) + except Exception as e: + logger.error(f"删除Gmail凭证失败: {str(e)}") + return Response({ + 'code': 500, + 'message': f'删除Gmail凭证失败: {str(e)}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=True, methods=['post']) + def set_default(self, request, pk=None): + """设置默认Gmail账号""" + try: + credential = self.get_object() + + # 取消其他默认账号 + GmailCredential.objects.filter( + user=request.user, + is_default=True + ).update(is_default=False) + + # 设置当前账号为默认 + credential.is_default = True + credential.save() + + return Response({ + 'code': 200, + 'message': f'已将 {credential.email} 设为默认Gmail账号', + 'data': None + }) + except Exception as e: + logger.error(f"设置默认Gmail账号失败: {str(e)}") + return Response({ + 'code': 500, + 'message': f'设置默认Gmail账号失败: {str(e)}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) class GmailConversationView(APIView): @@ -657,25 +777,40 @@ class GmailWebhookView(APIView): 这个端点不需要认证,因为它由Google的Pub/Sub服务调用。 """ permission_classes = [] # 不需要认证 - + def post(self, request): """ - 处理POST请求,接收Gmail Pub/Sub推送通知。 + 处理POST请求,接收Gmail Pub/Sub推送通知, + 从中获取邮箱地址和历史ID,然后保存最新邮件到数据库。 Args: request: Django REST Framework请求对象,包含Pub/Sub消息。 Returns: - Response: 接收结果的JSON响应。 + Response: 处理结果的JSON响应。 """ try: - logger.info(f"收到Gmail推送通知: {request.data}") + print("\n" + "="*100) + print("[Gmail Webhook] 收到推送通知") + print("="*100) + + # 打印请求时间和基本信息 + current_time = timezone.now() + print(f"接收时间: {current_time.strftime('%Y-%m-%d %H:%M:%S.%f')}") + print(f"请求头: {dict(request.headers)}") + print(f"原始数据: {request.data}") # 解析推送消息 message = request.data.get('message', {}) data = message.get('data', '') + message_id = message.get('messageId', '') + subscription = request.data.get('subscription', '') + + print(f"消息ID: {message_id}") + print(f"订阅名称: {subscription}") if not data: + print("[Gmail Webhook] 错误: 无效的推送消息格式,缺少data字段") return Response({ 'code': 400, 'message': '无效的推送消息格式', @@ -685,13 +820,17 @@ class GmailWebhookView(APIView): # Base64解码消息数据 try: decoded_data = json.loads(base64.b64decode(data).decode('utf-8')) - logger.info(f"解码后的推送数据: {decoded_data}") + print(f"[Gmail Webhook] 解码后的数据: {decoded_data}") - # 处理Gmail通知 + # 获取Gmail通知关键信息 email_address = decoded_data.get('emailAddress') history_id = decoded_data.get('historyId') + print(f"邮箱地址: {email_address}") + print(f"历史ID: {history_id}") + if not email_address: + print("[Gmail Webhook] 错误: 推送数据缺少邮箱地址") return Response({ 'code': 400, 'message': '推送数据缺少邮箱地址', @@ -701,28 +840,33 @@ class GmailWebhookView(APIView): # 查找对应的Gmail凭证 credential = GmailCredential.objects.filter(email=email_address, is_valid=True).first() if credential: - # 即使没有history_id,也尝试处理,因为我们现在有了备用机制 - if not history_id: - logger.warning(f"推送通知中没有historyId,将使用凭证中保存的历史ID") + user = credential.user + print(f"[Gmail Webhook] 找到有效凭证: 用户ID {user.id}, 邮箱 {email_address}") - # 启动后台任务处理新邮件 - thread = threading.Thread( - target=GmailService.process_new_emails, - args=(credential.user, credential, history_id) - ) - thread.daemon = True - thread.start() + # 获取并保存最新邮件 + print("[Gmail Webhook] 开始获取最新邮件...") + + # 处理新邮件,使用静态方法而不是实例化类 + try: + # 使用GmailService的静态方法处理新邮件 + processed_emails = GmailService.process_new_emails(user, credential, history_id) + print(f"[Gmail Webhook] 新邮件处理完成,共 {len(processed_emails)} 封") + + # 更新凭证的历史ID(移到处理完成后再更新) + if history_id: + credential.last_history_id = history_id + credential.save() + print(f"[Gmail Webhook] 已更新凭证历史ID: {history_id}") + + except Exception as e: + print(f"[Gmail Webhook] 获取最新邮件失败: {str(e)}") + logger.error(f"获取最新邮件失败: {str(e)}") else: + print(f"[Gmail Webhook] 警告: 未找到对应的Gmail凭证: {email_address}") logger.warning(f"收到推送通知,但未找到对应的Gmail凭证: {email_address}") - # 确认接收 - return Response({ - 'code': 200, - 'message': '成功接收推送通知', - 'data': None - }) - except Exception as e: + print(f"[Gmail Webhook] 解析推送数据失败: {str(e)}") logger.error(f"解析推送数据失败: {str(e)}") return Response({ 'code': 400, @@ -730,11 +874,22 @@ class GmailWebhookView(APIView): 'data': None }, status=status.HTTP_400_BAD_REQUEST) + print("[Gmail Webhook] 推送处理完成") + print("="*100 + "\n") + + # 返回成功响应 + return Response({ + 'code': 200, + 'message': '推送通知处理成功', + 'data': None + }) + except Exception as e: + print(f"[Gmail Webhook] 处理推送通知失败: {str(e)}") logger.error(f"处理Gmail推送通知失败: {str(e)}") return Response({ 'code': 500, - 'message': f'处理Gmail推送通知失败: {str(e)}', + 'message': f'处理推送通知失败: {str(e)}', 'data': None }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -818,3 +973,317 @@ class GmailConversationSummaryView(APIView): 'data': None }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + +class GmailGoalView(APIView): + """ + 用户目标API - 支持用户为每个对话设置不同的目标 + """ + permission_classes = [IsAuthenticated] + + def get(self, request, conversation_id=None): + """ + 获取用户的目标 + + 如果提供了conversation_id,则获取该对话的活跃目标 + 如果没有提供conversation_id,则获取用户的所有活跃目标 + """ + try: + if conversation_id: + # 获取特定对话的活跃目标 + goal = AutoGmailConversationService.get_active_goals_for_conversation( + request.user, + conversation_id + ) + + if not goal: + return Response({ + 'code': 404, + 'message': '未找到该对话的活跃目标', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) + + serializer = UserGoalSerializer(goal) + return Response({ + 'code': 200, + 'message': '获取目标成功', + 'data': serializer.data + }) + else: + # 获取用户的所有活跃目标 + goals = UserGoal.objects.filter(user=request.user, is_active=True) + + # 准备响应数据,包括对话信息 + result = [] + for goal in goals: + goal_data = UserGoalSerializer(goal).data + result.append(goal_data) + + return Response({ + 'code': 200, + 'message': '获取目标列表成功', + 'data': result + }) + + except Exception as e: + logger.error(f"获取用户目标失败: {str(e)}") + logger.error(traceback.format_exc()) + return Response({ + 'code': 500, + 'message': f'获取用户目标失败: {str(e)}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + def post(self, request): + """创建对话目标""" + try: + # 获取请求参数 + conversation_id = request.data.get('conversation_id') + goal_description = request.data.get('goal_description') + + # 验证必要参数 + if not conversation_id or not goal_description: + return Response({ + 'code': 400, + 'message': '缺少必要参数: conversation_id 或 goal_description', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 查找对话 + conversation = GmailConversation.objects.filter(conversation_id=conversation_id).first() + if not conversation: + return Response({ + 'code': 404, + 'message': '对话不存在', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) + + # 检查权限 + if conversation.user.id != request.user.id: + return Response({ + 'code': 403, + 'message': '无权限访问此对话', + 'data': None + }, status=status.HTTP_403_FORBIDDEN) + + # 查找现有目标 + existing_goal = UserGoal.objects.filter( + user=request.user, + conversation=conversation, + is_active=True + ).first() + + if existing_goal: + # 如果存在活跃目标,直接更新它 + existing_goal.description = goal_description + existing_goal.status = 'pending' # 重置状态 + existing_goal.last_activity_time = timezone.now() + existing_goal.metadata = existing_goal.metadata or {} + existing_goal.metadata.update({ + 'updated_at': timezone.now().isoformat(), + 'influencer_email': conversation.influencer_email, + 'user_email': conversation.user_email + }) + existing_goal.save() + + logger.info(f"用户 {request.user.username} 更新了对话 {conversation_id} 的目标") + serializer = UserGoalSerializer(existing_goal) + return Response({ + 'code': 200, + 'message': '目标更新成功', + 'data': serializer.data + }) + else: + # 创建新目标 + goal = UserGoal.objects.create( + user=request.user, + conversation=conversation, + description=goal_description, + is_active=True, + status='pending', + metadata={ + 'created_at': timezone.now().isoformat(), + 'influencer_email': conversation.influencer_email, + 'user_email': conversation.user_email + } + ) + + logger.info(f"用户 {request.user.username} 为对话 {conversation_id} 创建了新目标") + serializer = UserGoalSerializer(goal) + return Response({ + 'code': 201, + 'message': '目标创建成功', + 'data': serializer.data + }, status=status.HTTP_201_CREATED) + + except Exception as e: + logger.error(f"创建用户目标失败: {str(e)}") + logger.error(traceback.format_exc()) + return Response({ + 'code': 500, + 'message': f'创建用户目标失败: {str(e)}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + def delete(self, request, conversation_id): + """停用对话目标""" + try: + # 获取该对话的活跃目标 + goal = AutoGmailConversationService.get_active_goals_for_conversation( + request.user, + conversation_id + ) + + if not goal: + return Response({ + 'code': 404, + 'message': '未找到该对话的活跃目标', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) + + # 停用目标 + goal.is_active = False + goal.save() + + return Response({ + 'code': 200, + 'message': '目标已停用', + 'data': None + }) + + except Exception as e: + logger.error(f"停用目标失败: {str(e)}") + logger.error(traceback.format_exc()) + return Response({ + 'code': 500, + 'message': f'停用目标失败: {str(e)}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + + +class SimpleRecommendedReplyView(APIView): + """ + 通过conversation_id一键获取目标、对话摘要和推荐回复 + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + """ + 直接通过conversation_id获取推荐回复 + + 请求参数: + - conversation_id: 对话ID (必填) + + 响应: + - 目标信息 + - 对话摘要 + - 达人最后发送的消息 + - 推荐回复内容 + """ + try: + # 获取请求参数 + conversation_id = request.data.get('conversation_id') + + # 验证必填参数 + if not conversation_id: + return Response({ + 'code': 400, + 'message': '缺少必要参数: conversation_id', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 验证对话存在并且属于当前用户 + # 直接通过conversation_id查找,不进行UUID验证 + # Gmail对话ID可能是形如 gmail_ae51a9e8-ff35-4507-a63b-7d8091327e1e_44a2aaac 的格式 + try: + conversation = GmailConversation.objects.filter(conversation_id=conversation_id).first() + if not conversation: + return Response({ + 'code': 404, + 'message': '对话不存在', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) + + if conversation.user != request.user: + return Response({ + 'code': 403, + 'message': '无权限访问该对话', + 'data': None + }, status=status.HTTP_403_FORBIDDEN) + except Exception as e: + logger.error(f"查找对话失败: {str(e)}") + return Response({ + 'code': 500, + 'message': f'查找对话失败: {str(e)}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + # 1. 获取对话的活跃目标 + goal = UserGoal.objects.filter( + user=request.user, + conversation=conversation, + is_active=True + ).first() + + if not goal: + return Response({ + 'code': 404, + 'message': '未找到该对话的活跃目标', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) + + # 2. 获取对话摘要 + conversation_summary = get_conversation_summary(conversation_id) + if not conversation_summary: + conversation_summary = "无对话摘要" + + # 3. 获取最后一条达人消息 + last_message = get_last_message(conversation_id) + if not last_message: + return Response({ + 'code': 400, + 'message': '对话中没有达人消息,无法生成回复', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 4. 生成推荐回复 + reply_content, error = generate_recommended_reply( + request.user, + goal.description, + conversation_summary, + last_message + ) + + if error: + return Response({ + 'code': 500, + 'message': f'生成推荐回复失败: {error}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + # 返回完整数据 + return Response({ + 'code': 200, + 'message': '获取成功', + 'data': { + 'goal': { + 'id': str(goal.id), + 'description': goal.description, + 'status': goal.status + }, + 'conversation_summary': conversation_summary, + 'last_message': last_message, + 'recommended_reply': reply_content + } + }) + + except Exception as e: + logger.error(f"一键获取推荐回复失败: {str(e)}") + logger.error(traceback.format_exc()) + return Response({ + 'code': 500, + 'message': f'服务器错误: {str(e)}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + diff --git a/apps/operation/README.md b/apps/operation/README.md new file mode 100644 index 0000000..5442129 --- /dev/null +++ b/apps/operation/README.md @@ -0,0 +1,527 @@ +# Operation 模块接口文档 + +## 基础 URL + +所有接口的基础路径: `/api/operation/` + +## 通用响应格式 + +所有接口都返回以下格式的响应: + +```json +{ + "code": 200, // 状态码,200表示成功 + "message": "操作成功", // 操作结果描述 + "data": {} // 数据内容,具体格式根据接口不同而变化 +} +``` + +分页接口的响应格式: + +```json +{ + "code": 200, + "message": "获取数据成功", + "data": { + "count": 100, // 总记录数 + "next": "http://example.com/api/operation/xxx/?page=2", // 下一页链接,没有下一页则为null + "previous": null, // 上一页链接,没有上一页则为null + "results": [], // 当前页的数据列表 + "page": 1, // 当前页码 + "pages": 10, // 总页数 + "page_size": 10 // 每页记录数 + } +} +``` + +## 接口列表 + +### 1. 运营账号管理 + +#### 1.1 获取运营账号列表 + +- **URL**: `/operators/` +- **方法**: GET +- **参数**: + - `page`: 页码,默认1 + - `page_size`: 每页记录数,默认10 + +- **响应示例**: +```json +{ + "code": 200, + "message": "获取运营账号列表成功", + "data": { + "count": 10, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "username": "operator1", + "real_name": "张三", + "email": "zhangsan@example.com", + "phone": "13800138000", + "position": "editor", + "department": "内容部", + "is_active": true, + "created_at": "2023-09-01T10:00:00Z", + "updated_at": "2023-09-01T10:00:00Z" + } + ], + "page": 1, + "pages": 1, + "page_size": 10 + } +} +``` + +#### 1.2 获取运营账号详情 + +- **URL**: `/operators/{id}/` +- **方法**: GET +- **响应示例**: +```json +{ + "code": 200, + "message": "获取运营账号详情成功", + "data": { + "id": 1, + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "username": "operator1", + "real_name": "张三", + "email": "zhangsan@example.com", + "phone": "13800138000", + "position": "editor", + "department": "内容部", + "is_active": true, + "created_at": "2023-09-01T10:00:00Z", + "updated_at": "2023-09-01T10:00:00Z" + } +} +``` + +#### 1.3 创建运营账号 + +- **URL**: `/operators/` +- **方法**: POST +- **请求参数**: +```json +{ + "username": "operator1", + "password": "password123", + "real_name": "张三", + "email": "zhangsan@example.com", + "phone": "13800138000", + "position": "editor", + "department": "内容部" +} +``` +- **响应示例**: 同详情接口 + +#### 1.4 更新运营账号 + +- **URL**: `/operators/{id}/` +- **方法**: PUT/PATCH +- **请求参数**: 同创建接口,PATCH 可部分更新 +- **响应示例**: 同详情接口 + +#### 1.5 删除运营账号 + +- **URL**: `/operators/{id}/` +- **方法**: DELETE +- **响应示例**: +```json +{ + "code": 200, + "message": "运营账号已停用", + "data": null +} +``` + +### 2. 平台账号管理 + +#### 2.1 获取平台账号列表 + +- **URL**: `/platforms/` +- **方法**: GET +- **参数**: + - `page`: 页码,默认1 + - `page_size`: 每页记录数,默认10 + +- **响应示例**: +```json +{ + "code": 200, + "message": "获取平台账号列表成功", + "data": { + "count": 10, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "operator": 1, + "operator_name": "张三", + "status": "active", + "followers_count": 1000, + "description": "测试账号", + "tags": ["科技", "数码"], + "profile_image": "https://example.com/image.jpg", + "last_posting": "2023-09-01T10:00:00Z", + "created_at": "2023-09-01T10:00:00Z", + "updated_at": "2023-09-01T10:00:00Z", + "last_login": "2023-09-01T10:00:00Z", + "platforms": [ + { + "platform_name": "youtube", + "account_url": "https://youtube.com/channel/123", + "account_id": "channel123", + "account_name": "测试频道" + } + ], + "name": "youtube" + } + ], + "page": 1, + "pages": 1, + "page_size": 10 + } +} +``` + +#### 2.2 获取平台账号详情 + +- **URL**: `/platforms/{id}/` +- **方法**: GET +- **响应示例**: 类似列表但无分页 + +#### 2.3 创建平台账号 + +- **URL**: `/platforms/` +- **方法**: POST +- **请求参数**: +```json +{ + "operator": 1, + "name": "Shizuku", + "status": "active", + "followers_count": 1000, + "description": "测试频道", + "tags": ["Vlogs", "Foodie"], + "profile_image": "https://example.com/image.jpg", + "platforms": [ + { + "platform_name": "youtube", + "account_name": "测试频道", + "account_id": "channel123", + "account_url": "https://youtube.com/channel/123" + } + ] +} +``` +- **响应示例**: 同详情接口 + +#### 2.4 更新平台账号 + +- **URL**: `/platforms/{id}/` +- **方法**: PUT/PATCH +- **请求参数**: 同创建接口,PATCH 可部分更新 +- **响应示例**: 同详情接口 + +#### 2.5 删除平台账号 + +- **URL**: `/platforms/{id}/` +- **方法**: DELETE +- **响应示例**: +```json +{ + "code": 200, + "message": "平台账号已删除", + "data": null +} +``` + +#### 2.6 更新粉丝数 + +- **URL**: `/platforms/{id}/update_followers/` +- **方法**: POST +- **请求参数**: +```json +{ + "followers_count": 2000 +} +``` +- **响应示例**: 同详情接口 + +#### 2.7 更新账号资料 + +- **URL**: `/platforms/{id}/update_profile/` +- **方法**: POST +- **请求参数**: +```json +{ + "tags": ["科技", "数码"], + "profile_image": "https://example.com/new_image.jpg", + "last_posting": "2023-09-02T10:00:00Z" +} +``` +- **响应示例**: 同详情接口 + +### 3. 视频管理 + +#### 3.1 获取视频列表 + +- **URL**: `/videos/` +- **方法**: GET +- **参数**: + - `page`: 页码,默认1 + - `page_size`: 每页记录数,默认10 + +- **响应示例**: +```json +{ + "code": 200, + "message": "获取视频列表成功", + "data": { + "count": 10, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "platform_account": 1, + "platform_account_name": "测试频道", + "platform_name": "youtube", + "title": "测试视频", + "description": "这是一个测试视频", + "video_url": "https://youtube.com/watch?v=123", + "local_path": "/path/to/video.mp4", + "thumbnail_url": "https://example.com/thumb.jpg", + "status": "published", + "views_count": 1000, + "likes_count": 100, + "comments_count": 50, + "shares_count": 20, + "tags": ["测试", "视频"], + "publish_time": "2023-09-01T10:00:00Z", + "video_id": "v123", + "created_at": "2023-09-01T10:00:00Z", + "updated_at": "2023-09-01T10:00:00Z" + } + ], + "page": 1, + "pages": 1, + "page_size": 10 + } +} +``` + +#### 3.2 获取视频详情 + +- **URL**: `/videos/{id}/` +- **方法**: GET +- **响应示例**: 类似列表但无分页 + +#### 3.3 创建视频 + +- **URL**: `/videos/` +- **方法**: POST +- **请求参数**: +```json +{ + "platform_account": 1, + "title": "测试视频", + "description": "这是一个测试视频", + "status": "draft", + "tags": ["测试", "视频"] +} +``` +- **响应示例**: 同详情接口 + +#### 3.4 更新视频信息 + +- **URL**: `/videos/{id}/` +- **方法**: PUT/PATCH +- **请求参数**: 同创建接口,PATCH 可部分更新 +- **响应示例**: 同详情接口 + +#### 3.5 删除视频 + +- **URL**: `/videos/{id}/` +- **方法**: DELETE +- **响应示例**: +```json +{ + "code": 200, + "message": "视频记录已删除", + "data": null +} +``` + +#### 3.6 更新视频统计数据 + +- **URL**: `/videos/{id}/update_stats/` +- **方法**: POST +- **请求参数**: +```json +{ + "views_count": 2000, + "likes_count": 200, + "comments_count": 100, + "shares_count": 50 +} +``` +- **响应示例**: +```json +{ + "code": 200, + "message": "视频统计数据更新成功", + "data": { + "id": 1, + "title": "测试视频", + "views_count": 2000, + "likes_count": 200, + "comments_count": 100, + "shares_count": 50 + } +} +``` + +#### 3.7 发布视频 + +- **URL**: `/videos/{id}/publish/` +- **方法**: POST +- **请求参数**: +```json +{ + "video_url": "https://youtube.com/watch?v=123" +} +``` +- **响应示例**: +```json +{ + "code": 200, + "message": "视频已成功发布", + "data": { + "id": 1, + "title": "测试视频", + "status": "published", + "video_url": "https://youtube.com/watch?v=123", + "publish_time": "2023-09-02T10:00:00Z" + } +} +``` + +#### 3.8 上传视频 + +- **URL**: `/videos/upload_video/` +- **方法**: POST +- **请求参数**: + - `video_file`: 视频文件(multipart/form-data) + - `platform_account`: 平台账号ID + - `title`: 视频标题 + - `description`: 视频描述 + - `tags`: 视频标签 + +- **响应示例**: +```json +{ + "code": 200, + "message": "视频上传成功", + "data": { + "id": 1, + "title": "测试视频", + "status": "draft" + } +} +``` + +#### 3.9 手动发布视频 + +- **URL**: `/videos/{id}/manual_publish/` +- **方法**: POST +- **请求参数**: +```json +{ + "video_url": "https://youtube.com/watch?v=123" +} +``` +- **响应示例**: +```json +{ + "code": 200, + "message": "视频发布成功", + "data": { + "id": 1, + "title": "测试视频", + "status": "published", + "video_url": "https://youtube.com/watch?v=123", + "publish_time": "2023-09-02T10:00:00Z" + } +} +``` + +## 字段说明 + +### 运营账号(OperatorAccount) + +| 字段名 | 类型 | 说明 | +|------------|-----------|--------------------------------------------------| +| id | Integer | 自增主键ID | +| uuid | UUID | 唯一标识符 | +| username | String | 用户名 | +| password | String | 密码(创建/更新时传入,不会在响应中返回) | +| real_name | String | 真实姓名 | +| email | String | 邮箱 | +| phone | String | 电话号码 | +| position | String | 职位,可选值: editor(编辑)、planner(策划)、operator(运营)、admin(管理员) | +| department | String | 部门 | +| is_active | Boolean | 是否在职 | +| created_at | Datetime | 创建时间 | +| updated_at | Datetime | 更新时间 | + +### 平台账号(PlatformAccount) + +| 字段名 | 类型 | 说明 | +|----------------|-----------|--------------------------------------------------| +| id | Integer | 自增主键ID | +| operator | Integer | 关联运营账号ID | +| operator_name | String | 运营账号名称(只读) | +| name | String | 自定义账户名称,可用于分类和识别不同平台的账号 | +| platforms | Array | 平台信息数组,包含平台名称、账号名称、账号ID、账号URL | +| status | String | 账号状态,可选值: active(正常)、restricted(限流)、suspended(封禁)、inactive(未激活) | +| followers_count| Integer | 粉丝数 | +| description | String | 账号描述 | +| tags | Array | 标签数组 | +| profile_image | String | 账号头像URL | +| last_posting | Datetime | 最后发布时间 | +| created_at | Datetime | 创建时间 | +| updated_at | Datetime | 更新时间 | +| last_login | Datetime | 最后登录时间 | + +### 视频(Video) + +| 字段名 | 类型 | 说明 | +|----------------------|-----------|--------------------------------------------------| +| id | Integer | 自增主键ID | +| platform_account | Integer | 关联平台账号ID | +| platform_account_name| String | 平台账号名称(只读) | +| platform_name | String | 平台名称(只读) | +| title | String | 视频标题 | +| description | String | 视频描述 | +| video_url | String | 视频URL | +| local_path | String | 本地存储路径 | +| thumbnail_url | String | 缩略图URL | +| status | String | 视频状态,可选值: draft(草稿)、scheduled(已排期)、published(已发布)、failed(失败)、deleted(已删除) | +| views_count | Integer | 观看次数 | +| likes_count | Integer | 点赞数 | +| comments_count | Integer | 评论数 | +| shares_count | Integer | 分享数 | +| tags | Array | 标签数组 | +| publish_time | Datetime | 发布时间 | +| video_id | String | 视频ID | +| created_at | Datetime | 创建时间 | +| updated_at | Datetime | 更新时间 | + \ No newline at end of file diff --git a/apps/operation/admin.py b/apps/operation/admin.py index f0944c5..1b3c4d6 100644 --- a/apps/operation/admin.py +++ b/apps/operation/admin.py @@ -1,36 +1,40 @@ from django.contrib import admin -from .models import OperatorAccount, PlatformAccount, Video +from apps.operation.models import OperatorAccount, PlatformAccount, Video @admin.register(OperatorAccount) class OperatorAccountAdmin(admin.ModelAdmin): - list_display = ('username', 'real_name', 'email', 'phone', 'position', 'department', 'is_active', 'created_at') + list_display = ('username', 'real_name', 'position', 'department', 'is_active', 'created_at') list_filter = ('position', 'department', 'is_active') search_fields = ('username', 'real_name', 'email', 'phone') - date_hierarchy = 'created_at' - readonly_fields = ('created_at', 'updated_at') + ordering = ('-created_at',) @admin.register(PlatformAccount) class PlatformAccountAdmin(admin.ModelAdmin): - list_display = ('account_name', 'platform_name', 'operator', 'status', 'followers_count', 'last_posting', 'created_at') + list_display = ('account_name', 'platform_name', 'get_operator_name', 'status', 'followers_count', 'created_at') list_filter = ('platform_name', 'status') - search_fields = ('account_name', 'account_id', 'description') - date_hierarchy = 'created_at' - readonly_fields = ('created_at', 'updated_at') + search_fields = ('account_name', 'account_id', 'operator__real_name') + ordering = ('-created_at',) - def get_queryset(self, request): - """优化查询,减少数据库查询次数""" - queryset = super().get_queryset(request) - return queryset.select_related('operator') + def get_operator_name(self, obj): + return obj.operator.real_name + + get_operator_name.short_description = '运营账号' + get_operator_name.admin_order_field = 'operator__real_name' @admin.register(Video) class VideoAdmin(admin.ModelAdmin): - list_display = ('title', 'platform_account', 'status', 'views_count', 'likes_count', 'publish_time', 'created_at') - list_filter = ('status', 'created_at', 'publish_time') - search_fields = ('title', 'description', 'tags') - date_hierarchy = 'created_at' - readonly_fields = ('created_at', 'updated_at') + list_display = ('title', 'get_platform_name', 'get_account_name', 'status', 'views_count', 'created_at') + list_filter = ('status', 'platform_account__platform_name') + search_fields = ('title', 'description', 'platform_account__account_name') + ordering = ('-created_at',) - def get_queryset(self, request): - """优化查询,减少数据库查询次数""" - queryset = super().get_queryset(request) - return queryset.select_related('platform_account', 'platform_account__operator') + def get_platform_name(self, obj): + return obj.platform_account.get_platform_name_display() + + def get_account_name(self, obj): + return obj.platform_account.account_name + + get_platform_name.short_description = '平台' + get_platform_name.admin_order_field = 'platform_account__platform_name' + get_account_name.short_description = '账号名称' + get_account_name.admin_order_field = 'platform_account__account_name' diff --git a/apps/operation/migrations/0001_initial.py b/apps/operation/migrations/0001_initial.py index 6e9c0a7..a2fbfde 100644 --- a/apps/operation/migrations/0001_initial.py +++ b/apps/operation/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.5 on 2025-05-12 08:55 +# Generated by Django 5.1.5 on 2025-05-14 06:49 import django.db.models.deletion import uuid @@ -43,10 +43,10 @@ class Migration(migrations.Migration): ('account_id', models.CharField(max_length=100, verbose_name='账号ID')), ('status', models.CharField(choices=[('active', '正常'), ('restricted', '限流'), ('suspended', '封禁'), ('inactive', '未激活')], default='active', max_length=20, verbose_name='账号状态')), ('followers_count', models.IntegerField(default=0, verbose_name='粉丝数')), - ('account_url', models.URLField(verbose_name='账号链接')), + ('account_url', models.URLField(blank=True, null=True, verbose_name='账号链接')), ('description', models.TextField(blank=True, null=True, verbose_name='账号描述')), ('tags', models.CharField(blank=True, help_text='用逗号分隔的标签列表', max_length=255, null=True, verbose_name='标签')), - ('profile_image', models.URLField(blank=True, null=True, verbose_name='头像URL')), + ('profile_image', models.URLField(blank=True, null=True, verbose_name='账号头像')), ('last_posting', models.DateTimeField(blank=True, null=True, verbose_name='最后发布时间')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), @@ -75,7 +75,7 @@ class Migration(migrations.Migration): ('shares_count', models.IntegerField(default=0, verbose_name='分享数')), ('tags', models.CharField(blank=True, max_length=500, null=True, verbose_name='标签')), ('publish_time', models.DateTimeField(blank=True, null=True, verbose_name='发布时间')), - ('scheduled_time', models.DateTimeField(blank=True, null=True, verbose_name='计划发布时间')), + ('video_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='视频ID')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), ('platform_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='videos', to='operation.platformaccount', verbose_name='发布账号')), diff --git a/apps/operation/migrations/0002_alter_platformaccount_options_and_more.py b/apps/operation/migrations/0002_alter_platformaccount_options_and_more.py new file mode 100644 index 0000000..cc185a4 --- /dev/null +++ b/apps/operation/migrations/0002_alter_platformaccount_options_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 5.1.5 on 2025-05-15 03:09 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('operation', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='platformaccount', + options={'ordering': ['-created_at'], 'verbose_name': '平台账号', 'verbose_name_plural': '平台账号'}, + ), + migrations.AlterUniqueTogether( + name='platformaccount', + unique_together=set(), + ), + migrations.AddField( + model_name='platformaccount', + name='name', + field=models.CharField(blank=True, default='', max_length=100, verbose_name='账户名称'), + ), + migrations.AlterField( + model_name='platformaccount', + name='operator', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='platform_accounts', to='operation.operatoraccount', verbose_name='运营账号'), + ), + migrations.AlterField( + model_name='platformaccount', + name='platform_name', + field=models.CharField(choices=[('youtube', 'YouTube'), ('tiktok', 'TikTok'), ('bilibili', 'Bilibili'), ('facebook', 'Facebook'), ('instagram', 'Instagram'), ('twitter', 'Twitter'), ('other', '其他平台')], max_length=20, verbose_name='平台名称'), + ), + migrations.AlterField( + model_name='platformaccount', + name='profile_image', + field=models.URLField(blank=True, null=True, verbose_name='头像URL'), + ), + migrations.AlterField( + model_name='platformaccount', + name='tags', + field=models.CharField(blank=True, help_text='逗号分隔的标签列表', max_length=255, null=True, verbose_name='标签'), + ), + migrations.AddIndex( + model_name='platformaccount', + index=models.Index(fields=['platform_name'], name='operation_p_platfor_6e8678_idx'), + ), + migrations.AddIndex( + model_name='platformaccount', + index=models.Index(fields=['account_id'], name='operation_p_account_bdceaf_idx'), + ), + migrations.AddIndex( + model_name='platformaccount', + index=models.Index(fields=['status'], name='operation_p_status_167573_idx'), + ), + ] diff --git a/apps/operation/models.py b/apps/operation/models.py index 3050fc1..1cebd1b 100644 --- a/apps/operation/models.py +++ b/apps/operation/models.py @@ -1,18 +1,15 @@ from django.db import models import uuid from django.utils import timezone -from apps.knowledge_base.models import KnowledgeBase, KnowledgeBaseDocument -from apps.accounts.models import User # Create your models here. # 我们可以在这里添加额外的模型或关系,但现在使用user_management中的现有模型 -# 从user_management迁移过来的模型 class OperatorAccount(models.Model): """运营账号信息表""" - id = models.AutoField(primary_key=True) # 保留自动递增的ID字段 + id = models.AutoField(primary_key=True) uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID') POSITION_CHOICES = [ @@ -36,54 +33,57 @@ class OperatorAccount(models.Model): class Meta: verbose_name = '运营账号' verbose_name_plural = '运营账号' - + def __str__(self): return f"{self.real_name} ({self.username})" class PlatformAccount(models.Model): - """平台账号信息表""" + """平台账号模型""" + PLATFORM_CHOICES = [ + ('youtube', 'YouTube'), + ('tiktok', 'TikTok'), + ('bilibili', 'Bilibili'), + ('facebook', 'Facebook'), + ('instagram', 'Instagram'), + ('twitter', 'Twitter'), + ('other', '其他平台') + ] STATUS_CHOICES = [ ('active', '正常'), ('restricted', '限流'), ('suspended', '封禁'), - ('inactive', '未激活'), + ('inactive', '未激活') ] - PLATFORM_CHOICES = [ - ('youtube', 'YouTube'), - ('tiktok', 'TikTok'), - ('twitter', 'Twitter/X'), - ('instagram', 'Instagram'), - ('facebook', 'Facebook'), - ('bilibili', 'Bilibili'), - ] - - operator = models.ForeignKey(OperatorAccount, on_delete=models.CASCADE, related_name='platform_accounts', verbose_name='关联运营') + operator = models.ForeignKey(OperatorAccount, on_delete=models.CASCADE, related_name='platform_accounts', verbose_name='运营账号') + name = models.CharField(max_length=100, verbose_name='账户名称', default='', blank=True) platform_name = models.CharField(max_length=20, choices=PLATFORM_CHOICES, verbose_name='平台名称') account_name = models.CharField(max_length=100, verbose_name='账号名称') account_id = models.CharField(max_length=100, verbose_name='账号ID') status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active', verbose_name='账号状态') followers_count = models.IntegerField(default=0, verbose_name='粉丝数') - account_url = models.URLField(verbose_name='账号链接') + account_url = models.URLField(blank=True, null=True, verbose_name='账号链接') description = models.TextField(blank=True, null=True, verbose_name='账号描述') - - # 新增字段 - tags = models.CharField(max_length=255, blank=True, null=True, verbose_name='标签', help_text='用逗号分隔的标签列表') + tags = models.CharField(max_length=255, blank=True, null=True, verbose_name='标签', help_text='逗号分隔的标签列表') profile_image = models.URLField(blank=True, null=True, verbose_name='头像URL') last_posting = models.DateTimeField(blank=True, null=True, verbose_name='最后发布时间') - created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') last_login = models.DateTimeField(blank=True, null=True, verbose_name='最后登录时间') + def __str__(self): + return f"{self.platform_name} - {self.account_name}" + class Meta: verbose_name = '平台账号' verbose_name_plural = '平台账号' - unique_together = ('platform_name', 'account_id') - - def __str__(self): - return f"{self.account_name} ({self.platform_name})" + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['platform_name']), + models.Index(fields=['account_id']), + models.Index(fields=['status']), + ] class Video(models.Model): """视频信息表""" @@ -109,18 +109,13 @@ class Video(models.Model): shares_count = models.IntegerField(default=0, verbose_name='分享数') tags = models.CharField(max_length=500, blank=True, null=True, verbose_name='标签') publish_time = models.DateTimeField(blank=True, null=True, verbose_name='发布时间') - scheduled_time = models.DateTimeField(blank=True, null=True, verbose_name='计划发布时间') + video_id = models.CharField(max_length=100, blank=True, null=True, verbose_name='视频ID') created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') class Meta: verbose_name = '视频' verbose_name_plural = '视频' - + def __str__(self): - return self.title - - def save(self, *args, **kwargs): - if self.status == 'published' and not self.publish_time: - self.publish_time = timezone.now() - super().save(*args, **kwargs) + return f"{self.title} ({self.platform_account.account_name})" diff --git a/apps/operation/pagination.py b/apps/operation/pagination.py index febb3cc..7a6939a 100644 --- a/apps/operation/pagination.py +++ b/apps/operation/pagination.py @@ -8,6 +8,14 @@ class CustomPagination(PageNumberPagination): max_page_size = 100 def get_paginated_response(self, data): + # 为那些没有name字段或name字段为空的项目设置默认值 + for item in data: + if 'platforms' in item and len(item['platforms']) > 0: + # 只有当name为空或不存在时,才使用platform_name作为默认值 + if not item.get('name'): + platform = item['platforms'][0] + item['name'] = platform.get('platform_name', '') + return Response({ "code": 200, "message": "获取数据成功", diff --git a/apps/operation/serializers.py b/apps/operation/serializers.py index a091276..8cc7b60 100644 --- a/apps/operation/serializers.py +++ b/apps/operation/serializers.py @@ -1,46 +1,39 @@ from rest_framework import serializers from .models import OperatorAccount, PlatformAccount, Video -from apps.knowledge_base.models import KnowledgeBase, KnowledgeBaseDocument import uuid +from django.db.models import Q class OperatorAccountSerializer(serializers.ModelSerializer): - id = serializers.UUIDField(read_only=False, required=False) # 允许前端不提供ID,但如果提供则必须是有效的UUID + id = serializers.IntegerField(read_only=True) # ID是自动递增的整数字段 class Meta: model = OperatorAccount - fields = ['id', 'username', 'password', 'real_name', 'email', 'phone', 'position', 'department', 'is_active', 'created_at', 'updated_at'] - read_only_fields = ['created_at', 'updated_at'] + fields = ['id', 'uuid', 'username', 'password', 'real_name', 'email', 'phone', 'position', 'department', 'is_active', 'created_at', 'updated_at'] + read_only_fields = ['created_at', 'updated_at', 'uuid'] extra_kwargs = { 'password': {'write_only': True} } - - def create(self, validated_data): - # 如果没有提供ID,则生成一个UUID - if 'id' not in validated_data: - validated_data['id'] = uuid.uuid4() - - password = validated_data.pop('password', None) - instance = self.Meta.model(**validated_data) - if password: - instance.password = password # 在实际应用中应该加密存储密码 - instance.save() - return instance class PlatformAccountSerializer(serializers.ModelSerializer): operator_name = serializers.CharField(source='operator.real_name', read_only=True) + platforms = serializers.SerializerMethodField() class Meta: model = PlatformAccount - fields = ['id', 'operator', 'operator_name', 'platform_name', 'account_name', 'account_id', + fields = ['id', 'operator', 'operator_name', 'name', 'platform_name', 'account_name', 'account_id', 'status', 'followers_count', 'account_url', 'description', 'tags', 'profile_image', 'last_posting', - 'created_at', 'updated_at', 'last_login'] + 'created_at', 'updated_at', 'last_login', 'platforms'] read_only_fields = ['id', 'created_at', 'updated_at'] + def get_platforms(self, obj): + # 不会在查询时调用,但需要实现该方法,默认返回空数组 + return [] + def to_internal_value(self, data): - # 处理operator字段,可能是字符串格式的UUID + # 处理operator字段,可能是字符串格式的ID if 'operator' in data and isinstance(data['operator'], str): try: # 尝试获取对应的运营账号对象 @@ -53,7 +46,94 @@ class PlatformAccountSerializer(serializers.ModelSerializer): # 其他类型的错误,如ID格式不正确等 pass + # 处理platforms字段,从数组中提取出第一个元素的信息 + if 'platforms' in data and isinstance(data['platforms'], list) and len(data['platforms']) > 0: + data = data.copy() + platform_data = data['platforms'][0] + # 将platforms中的字段移到顶层 + if 'platform_name' in platform_data: + data['platform_name'] = platform_data['platform_name'] + if 'account_url' in platform_data: + data['account_url'] = platform_data['account_url'] + if 'account_id' in platform_data: + data['account_id'] = platform_data['account_id'] + if 'account_name' in platform_data: + data['account_name'] = platform_data['account_name'] + + # 删除platforms字段,避免验证错误 + del data['platforms'] + + # 处理tags字段,将列表转换为逗号分隔的字符串 + if 'tags' in data and isinstance(data['tags'], list): + data = data.copy() if not isinstance(data, dict) else data + data['tags'] = ','.join(data['tags']) + return super().to_internal_value(data) + + def to_representation(self, instance): + """将tags字符串转换为数组""" + representation = super().to_representation(instance) + if representation.get('tags'): + representation['tags'] = representation['tags'].split(',') + return representation + + +class PlatformDetailSerializer(serializers.Serializer): + """平台详情序列化器,用于多平台账号创建""" + platform_name = serializers.ChoiceField(choices=PlatformAccount.PLATFORM_CHOICES) + platform_url = serializers.URLField() + + +class MultiPlatformAccountSerializer(serializers.Serializer): + """多平台账号创建序列化器""" + operator = serializers.PrimaryKeyRelatedField(queryset=OperatorAccount.objects.all()) + name = serializers.CharField(max_length=100, required=False, allow_blank=True) + account_name = serializers.CharField(max_length=100) + account_id = serializers.CharField(max_length=100) + status = serializers.ChoiceField(choices=PlatformAccount.STATUS_CHOICES, default='active') + followers_count = serializers.IntegerField(default=0) + description = serializers.CharField(required=False, allow_blank=True, allow_null=True) + # 使用CharField而不是ListField,我们会在to_internal_value和to_representation中手动处理转换 + tags = serializers.CharField(required=False, allow_blank=True, allow_null=True, max_length=255) + profile_image = serializers.URLField(required=False, allow_blank=True, allow_null=True) + last_posting = serializers.DateTimeField(required=False, allow_null=True) + platforms = PlatformDetailSerializer(many=True) + + def to_internal_value(self, data): + # 处理tags字段,将列表转换为逗号分隔的字符串 + if 'tags' in data and isinstance(data['tags'], list): + data = data.copy() + data['tags'] = ','.join(data['tags']) + + # 处理operator字段,可能是字符串类型的ID + if 'operator' in data and isinstance(data['operator'], str): + try: + # 尝试通过ID查找运营账号 + operator_id = data['operator'] + try: + # 先尝试通过整数ID查找 + operator_id_int = int(operator_id) + operator = OperatorAccount.objects.get(id=operator_id_int) + data['operator'] = operator.id + except (ValueError, OperatorAccount.DoesNotExist): + # 如果无法转换为整数或找不到对应账号,尝试通过用户名或真实姓名查找 + operator = OperatorAccount.objects.filter( + Q(username=operator_id) | Q(real_name=operator_id) + ).first() + + if operator: + data['operator'] = operator.id + except Exception as e: + pass + + return super().to_internal_value(data) + + def to_representation(self, instance): + """将tags字符串转换为数组""" + representation = super().to_representation(instance) + if representation.get('tags') and isinstance(representation['tags'], str): + representation['tags'] = representation['tags'].split(',') + return representation class VideoSerializer(serializers.ModelSerializer): @@ -65,12 +145,17 @@ class VideoSerializer(serializers.ModelSerializer): fields = ['id', 'platform_account', 'platform_account_name', 'platform_name', 'title', 'description', 'video_url', 'local_path', 'thumbnail_url', 'status', 'views_count', 'likes_count', 'comments_count', 'shares_count', 'tags', - 'publish_time', 'scheduled_time', 'created_at', 'updated_at'] + 'publish_time', 'video_id', 'created_at', 'updated_at'] read_only_fields = ['id', 'created_at', 'updated_at', 'views_count', 'likes_count', 'comments_count', 'shares_count'] def to_internal_value(self, data): - # 处理platform_account字段,可能是字符串格式的UUID + # 处理tags字段,将列表转换为逗号分隔的字符串 + if 'tags' in data and isinstance(data['tags'], list): + data = data.copy() + data['tags'] = ','.join(data['tags']) + + # 处理platform_account字段,可能是字符串格式的ID if 'platform_account' in data and isinstance(data['platform_account'], str): try: # 尝试获取对应的平台账号对象 @@ -84,19 +169,11 @@ class VideoSerializer(serializers.ModelSerializer): pass return super().to_internal_value(data) - - -class KnowledgeBaseSerializer(serializers.ModelSerializer): - class Meta: - model = KnowledgeBase - fields = ['id', 'user_id', 'name', 'desc', 'type', 'department', 'group', - 'external_id', 'create_time', 'update_time'] - read_only_fields = ['id', 'create_time', 'update_time'] - - -class KnowledgeBaseDocumentSerializer(serializers.ModelSerializer): - class Meta: - model = KnowledgeBaseDocument - fields = ['id', 'knowledge_base', 'document_id', 'document_name', - 'external_id', 'uploader_name', 'status', 'create_time', 'update_time'] - read_only_fields = ['id', 'create_time', 'update_time'] \ No newline at end of file + + def to_representation(self, instance): + """将tags字符串转换为数组""" + representation = super().to_representation(instance) + if representation.get('tags'): + representation['tags'] = representation['tags'].split(',') + return representation + \ No newline at end of file diff --git a/apps/operation/views.py b/apps/operation/views.py index 91a7eda..939900e 100644 --- a/apps/operation/views.py +++ b/apps/operation/views.py @@ -9,16 +9,13 @@ from django.utils import timezone from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response -from rest_framework.permissions import IsAuthenticated from django.db.models import Q import os from .models import OperatorAccount, PlatformAccount, Video -from apps.knowledge_base.models import KnowledgeBase, KnowledgeBaseDocument -from apps.accounts.models import User from .serializers import ( OperatorAccountSerializer, PlatformAccountSerializer, VideoSerializer, - KnowledgeBaseSerializer, KnowledgeBaseDocumentSerializer + MultiPlatformAccountSerializer ) from .pagination import CustomPagination @@ -77,118 +74,28 @@ class OperatorAccountViewSet(viewsets.ModelViewSet): return self.update(request, *args, **kwargs) def create(self, request, *args, **kwargs): - """创建运营账号并自动创建对应的私有知识库""" - with transaction.atomic(): - # 1. 创建运营账号 - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - - # 2. 手动保存数据而不是使用serializer.save(),确保不传入UUID - operator_data = serializer.validated_data - operator = OperatorAccount.objects.create(**operator_data) - - # 3. 为每个运营账号创建一个私有知识库 - knowledge_base = KnowledgeBase.objects.create( - user_id=request.user.id, # 使用当前用户作为创建者 - name=f"{operator.real_name}的运营知识库", - desc=f"用于存储{operator.real_name}({operator.username})相关的运营数据", - type='private', - department=operator.department - ) - - # 4. 创建知识库文档记录 - 运营信息文档 - document_data = { - "name": f"{operator.real_name}_运营信息", - "paragraphs": [ - { - "title": "运营账号基本信息", - "content": f""" - 用户名: {operator.username} - 真实姓名: {operator.real_name} - 邮箱: {operator.email} - 电话: {operator.phone} - 职位: {operator.get_position_display()} - 部门: {operator.department} - 创建时间: {operator.created_at.strftime('%Y-%m-%d %H:%M:%S')} - uuid: {operator.uuid} - """, - "is_active": True - } - ] - } - - # 调用外部API创建文档 - document_id = self._create_document(knowledge_base.external_id, document_data) - - if document_id: - # 创建知识库文档记录 - KnowledgeBaseDocument.objects.create( - knowledge_base=knowledge_base, - document_id=document_id, - document_name=document_data["name"], - external_id=document_id, - uploader_name=request.user.username - ) - - return Response({ - "code": 200, - "message": "运营账号创建成功,并已创建对应知识库", - "data": { - "operator": self.get_serializer(operator).data, - "knowledge_base": { - "id": knowledge_base.id, - "name": knowledge_base.name, - "external_id": knowledge_base.external_id - } - } - }, status=status.HTTP_201_CREATED) + """创建运营账号""" + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + return Response({ + "code": 200, + "message": "运营账号创建成功", + "data": serializer.data + }, status=status.HTTP_201_CREATED) def destroy(self, request, *args, **kwargs): - """删除运营账号并更新相关知识库状态""" + """删除运营账号""" operator = self.get_object() - - # 更新知识库状态或删除关联文档 - knowledge_bases = KnowledgeBase.objects.filter( - name__contains=operator.real_name, - type='private' - ) - - for kb in knowledge_bases: - # 可以选择删除知识库,或者更新知识库状态 - # 这里我们更新对应的文档状态 - documents = KnowledgeBaseDocument.objects.filter( - knowledge_base=kb, - document_name__contains=operator.real_name - ) - - for doc in documents: - doc.status = 'deleted' - doc.save() - operator.is_active = False # 软删除 operator.save() return Response({ "code": 200, - "message": "运营账号已停用,相关知识库文档已标记为删除", + "message": "运营账号已停用", "data": None }) - - def _create_document(self, external_id, doc_data): - """调用外部API创建文档""" - try: - if not external_id: - logger.error("创建文档失败:知识库external_id为空") - return None - - # 在实际应用中,这里需要调用外部API创建文档 - # 模拟创建文档并返回document_id - document_id = str(uuid.uuid4()) - logger.info(f"模拟创建文档成功,document_id: {document_id}") - return document_id - except Exception as e: - logger.error(f"创建文档失败: {str(e)}") - return None class PlatformAccountViewSet(viewsets.ModelViewSet): @@ -204,38 +111,149 @@ class PlatformAccountViewSet(viewsets.ModelViewSet): if page is not None: serializer = self.get_serializer(page, many=True) - # 使用自定义分页器的响应 - return self.get_paginated_response(serializer.data) + # 处理数据结构 + response_data = serializer.data + restructured_data = [] + for account_data in response_data: + # 提取平台信息并放入platforms字段 + platform_info = { + "platform_name": account_data.pop("platform_name"), + "account_url": account_data.pop("account_url"), + "account_id": account_data.pop("account_id"), + "account_name": account_data.pop("account_name") + } + # 添加platforms字段作为数组 + account_data["platforms"] = [platform_info] + # 保留用户传入的name字段,如果没有则使用platform_name + if not account_data.get("name"): + account_data["name"] = platform_info["platform_name"] + restructured_data.append(account_data) + + # 使用自定义分页器的响应,但替换数据 + return self.get_paginated_response(restructured_data) serializer = self.get_serializer(queryset, many=True) + # 处理数据结构 + response_data = serializer.data + restructured_data = [] + for account_data in response_data: + # 提取平台信息并放入platforms字段 + platform_info = { + "platform_name": account_data.pop("platform_name"), + "account_url": account_data.pop("account_url"), + "account_id": account_data.pop("account_id"), + "account_name": account_data.pop("account_name") + } + # 添加platforms字段作为数组 + account_data["platforms"] = [platform_info] + # 保留用户传入的name字段,如果没有则使用platform_name + if not account_data.get("name"): + account_data["name"] = platform_info["platform_name"] + restructured_data.append(account_data) + return Response({ "code": 200, "message": "获取平台账号列表成功", - "data": serializer.data + "data": restructured_data }) def retrieve(self, request, *args, **kwargs): """获取平台账号详情""" instance = self.get_object() serializer = self.get_serializer(instance) + # 处理数据结构 + account_data = serializer.data + # 提取平台信息并放入platforms字段 + platform_info = { + "platform_name": account_data.pop("platform_name"), + "account_url": account_data.pop("account_url"), + "account_id": account_data.pop("account_id"), + "account_name": account_data.pop("account_name") + } + # 添加platforms字段 + account_data["platforms"] = [platform_info] + # 保留用户传入的name字段,如果没有则使用platform_name + if not account_data.get("name"): + account_data["name"] = platform_info["platform_name"] + return Response({ "code": 200, "message": "获取平台账号详情成功", - "data": serializer.data + "data": account_data }) def update(self, request, *args, **kwargs): """更新平台账号信息""" partial = kwargs.pop('partial', False) instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data, partial=partial) + + # 单独处理platforms字段,多平台信息需要特殊处理 + data = request.data.copy() + platforms_data = None + + if 'platforms' in data and isinstance(data['platforms'], list): + platforms_data = data.pop('platforms') + + # 如果有platforms数据,先将第一个平台的信息移至顶层,保证基本平台信息能正常更新 + if platforms_data and len(platforms_data) > 0: + first_platform = platforms_data[0] + if 'platform_name' in first_platform: + data['platform_name'] = first_platform['platform_name'] + if 'account_url' in first_platform: + data['account_url'] = first_platform['account_url'] + if 'account_id' in first_platform: + data['account_id'] = first_platform['account_id'] + if 'account_name' in first_platform: + data['account_name'] = first_platform['account_name'] + + serializer = self.get_serializer(instance, data=data, partial=partial) serializer.is_valid(raise_exception=True) self.perform_update(serializer) + # 处理多平台信息 + # 这里我们需要实现新的逻辑来处理额外的平台 + # 注意:这需要修改模型结构或添加关联模型来支持多平台 + # 由于当前模型结构限制,我们暂时只能在此记录其他平台数据 + # 用于未来扩展,目前会在日志中记录这些信息 + if platforms_data and len(platforms_data) > 1: + logger = logging.getLogger(__name__) + logger.info(f"接收到多平台数据,但当前版本仅支持一个平台。额外平台数据: {platforms_data[1:]}") + + # 这里应该添加创建关联平台记录的代码 + # 例如: + # for platform_data in platforms_data[1:]: + # RelatedPlatform.objects.create( + # primary_account=instance, + # platform_name=platform_data.get('platform_name', ''), + # account_id=platform_data.get('account_id', ''), + # account_name=platform_data.get('account_name', ''), + # account_url=platform_data.get('account_url', '') + # ) + + # 处理数据结构 + account_data = serializer.data + # 提取平台信息并放入platforms字段 + platform_info = { + "platform_name": account_data.pop("platform_name"), + "account_url": account_data.pop("account_url"), + "account_id": account_data.pop("account_id"), + "account_name": account_data.pop("account_name") + } + # 添加platforms字段 + # 如果有platforms_data,使用原始请求中的数据,否则使用当前的单平台数据 + if platforms_data: + account_data["platforms"] = platforms_data + else: + account_data["platforms"] = [platform_info] + + # 保留用户传入的name字段,如果没有则使用platform_name + if not account_data.get("name"): + account_data["name"] = platform_info["platform_name"] + return Response({ "code": 200, "message": "更新平台账号信息成功", - "data": serializer.data + "data": account_data }) def partial_update(self, request, *args, **kwargs): @@ -244,7 +262,9 @@ class PlatformAccountViewSet(viewsets.ModelViewSet): return self.update(request, *args, **kwargs) def create(self, request, *args, **kwargs): - """创建平台账号并记录到知识库""" + """创建平台账号""" + # 传统单平台账号创建流程 + print(f"{request.data}") with transaction.atomic(): # 处理operator字段,可能是字符串类型的ID data = request.data.copy() @@ -283,118 +303,44 @@ class PlatformAccountViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(data=data) serializer.is_valid(raise_exception=True) - # 手动创建平台账号,不使用serializer.save()避免ID问题 - platform_data = serializer.validated_data - platform_account = PlatformAccount.objects.create(**platform_data) + # 创建平台账号 + self.perform_create(serializer) - # 获取关联的运营账号 - operator = platform_account.operator - - # 查找对应的知识库 - knowledge_base = KnowledgeBase.objects.filter( - name__contains=operator.real_name, - type='private' - ).first() - - if knowledge_base and knowledge_base.external_id: - # 创建平台账号文档 - document_data = { - "name": f"{platform_account.account_name}_{platform_account.platform_name}_账号信息", - "paragraphs": [ - { - "title": "平台账号基本信息", - "content": f""" - 平台: {platform_account.get_platform_name_display()} - 账号名称: {platform_account.account_name} - 账号ID: {platform_account.account_id} - 账号状态: {platform_account.get_status_display()} - 粉丝数: {platform_account.followers_count} - 账号链接: {platform_account.account_url} - 账号描述: {platform_account.description or '无'} - 标签: {platform_account.tags or '无'} - 头像链接: {platform_account.profile_image or '无'} - 最后发布时间: {platform_account.last_posting.strftime('%Y-%m-%d %H:%M:%S') if platform_account.last_posting else '未发布'} - 创建时间: {platform_account.created_at.strftime('%Y-%m-%d %H:%M:%S')} - 最后登录: {platform_account.last_login.strftime('%Y-%m-%d %H:%M:%S') if platform_account.last_login else '从未登录'} - """, - "is_active": True - } - ] - } - - # 调用外部API创建文档 - document_id = self._create_document(knowledge_base.external_id, document_data) - - if document_id: - # 创建知识库文档记录 - KnowledgeBaseDocument.objects.create( - knowledge_base=knowledge_base, - document_id=document_id, - document_name=document_data["name"], - external_id=document_id, - uploader_name=request.user.username - ) + # 处理响应数据 + account_data = serializer.data + # 提取平台信息并放入platforms字段 + platform_info = { + "platform_name": account_data.pop("platform_name"), + "account_url": account_data.pop("account_url"), + "account_id": account_data.pop("account_id"), + "account_name": account_data.pop("account_name") + } + # 添加platforms字段 + account_data["platforms"] = [platform_info] + # 保留用户传入的name字段,如果没有则使用platform_name + if not account_data.get("name"): + account_data["name"] = platform_info["platform_name"] return Response({ "code": 200, - "message": "平台账号创建成功,并已添加到知识库", - "data": self.get_serializer(platform_account).data + "message": "平台账号创建成功", + "data": account_data }, status=status.HTTP_201_CREATED) def destroy(self, request, *args, **kwargs): - """删除平台账号并更新相关知识库文档""" + """删除平台账号""" platform_account = self.get_object() - - # 获取关联的运营账号 - operator = platform_account.operator - - # 查找对应的知识库 - knowledge_base = KnowledgeBase.objects.filter( - name__contains=operator.real_name, - type='private' - ).first() - - if knowledge_base: - # 查找相关文档并标记为删除 - documents = KnowledgeBaseDocument.objects.filter( - knowledge_base=knowledge_base - ).filter( - Q(document_name__contains=platform_account.account_name) | - Q(document_name__contains=platform_account.platform_name) - ) - - for doc in documents: - doc.status = 'deleted' - doc.save() - - # 删除平台账号 self.perform_destroy(platform_account) return Response({ "code": 200, - "message": "平台账号已删除,相关知识库文档已标记为删除", + "message": "平台账号已删除", "data": None }) - def _create_document(self, external_id, doc_data): - """调用外部API创建文档""" - try: - if not external_id: - logger.error("创建文档失败:知识库external_id为空") - return None - - # 在实际应用中,这里需要调用外部API创建文档 - # 模拟创建文档并返回document_id - document_id = str(uuid.uuid4()) - logger.info(f"模拟创建文档成功,document_id: {document_id}") - return document_id - except Exception as e: - logger.error(f"创建文档失败: {str(e)}") - return None - @action(detail=True, methods=['post']) def update_followers(self, request, pk=None): - """更新平台账号粉丝数并同步到知识库""" + """更新平台账号粉丝数""" platform_account = self.get_object() followers_count = request.data.get('followers_count') @@ -409,36 +355,25 @@ class PlatformAccountViewSet(viewsets.ModelViewSet): platform_account.followers_count = followers_count platform_account.save() - # 同步到知识库 - operator = platform_account.operator - knowledge_base = KnowledgeBase.objects.filter( - name__contains=operator.real_name, - type='private' - ).first() - - if knowledge_base: - # 查找相关文档 - document = KnowledgeBaseDocument.objects.filter( - knowledge_base=knowledge_base, - status='active' - ).filter( - Q(document_name__contains=platform_account.account_name) | - Q(document_name__contains=platform_account.platform_name) - ).first() - - if document: - # 这里应该调用外部API更新文档内容 - # 但由于我们没有实际的API,只做记录 - logger.info(f"应当更新文档 {document.document_id} 的粉丝数为 {followers_count}") + # 准备响应数据,与其他方法保持一致 + platform_data = self.get_serializer(platform_account).data + # 提取平台信息并放入platforms字段 + platform_info = { + "platform_name": platform_data.pop("platform_name"), + "account_url": platform_data.pop("account_url"), + "account_id": platform_data.pop("account_id"), + "account_name": platform_data.pop("account_name") + } + # 添加platforms字段 + platform_data["platforms"] = [platform_info] + # 保留用户传入的name字段,如果没有则使用platform_name + if not platform_data.get("name"): + platform_data["name"] = platform_info["platform_name"] return Response({ "code": 200, "message": "粉丝数更新成功", - "data": { - "id": platform_account.id, - "account_name": platform_account.account_name, - "followers_count": platform_account.followers_count - } + "data": platform_data }) @action(detail=True, methods=['post']) @@ -451,7 +386,12 @@ class PlatformAccountViewSet(viewsets.ModelViewSet): # 处理标签 if 'tags' in request.data: - profile_data['tags'] = request.data['tags'] + # 处理tags,支持字符串或数组格式 + tags = request.data['tags'] + if isinstance(tags, list): + profile_data['tags'] = ','.join(tags) + else: + profile_data['tags'] = tags # 处理头像 if 'profile_image' in request.data: @@ -470,6 +410,10 @@ class PlatformAccountViewSet(viewsets.ModelViewSet): "message": f"最后发布时间格式错误: {str(e)}", "data": None }, status=status.HTTP_400_BAD_REQUEST) + + # 处理名称 + if 'name' in request.data: + profile_data['name'] = request.data['name'] if not profile_data: return Response({ @@ -483,30 +427,25 @@ class PlatformAccountViewSet(viewsets.ModelViewSet): setattr(platform_account, field, value) platform_account.save() - # 同步到知识库 - # 在实际应用中应该调用外部API更新文档内容 - operator = platform_account.operator - knowledge_base = KnowledgeBase.objects.filter( - name__contains=operator.real_name, - type='private' - ).first() - - if knowledge_base: - document = KnowledgeBaseDocument.objects.filter( - knowledge_base=knowledge_base, - status='active' - ).filter( - Q(document_name__contains=platform_account.account_name) | - Q(document_name__contains=platform_account.platform_name) - ).first() - - if document: - logger.info(f"应当更新文档 {document.document_id} 的平台账号资料数据") + # 准备响应数据,与其他方法保持一致 + platform_data = self.get_serializer(platform_account).data + # 提取平台信息并放入platforms字段 + platform_info = { + "platform_name": platform_data.pop("platform_name"), + "account_url": platform_data.pop("account_url"), + "account_id": platform_data.pop("account_id"), + "account_name": platform_data.pop("account_name") + } + # 添加platforms字段 + platform_data["platforms"] = [platform_info] + # 保留用户传入的name字段,如果没有则使用platform_name + if not platform_data.get("name"): + platform_data["name"] = platform_info["platform_name"] return Response({ "code": 200, "message": "平台账号资料更新成功", - "data": self.get_serializer(platform_account).data + "data": platform_data }) @@ -563,7 +502,7 @@ class VideoViewSet(viewsets.ModelViewSet): return self.update(request, *args, **kwargs) def create(self, request, *args, **kwargs): - """创建视频并记录到知识库""" + """创建视频""" with transaction.atomic(): # 处理platform_account字段,可能是字符串类型的ID data = request.data.copy() @@ -602,117 +541,29 @@ class VideoViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(data=data) serializer.is_valid(raise_exception=True) - # 手动创建视频,不使用serializer.save()避免ID问题 - video_data = serializer.validated_data - video = Video.objects.create(**video_data) - - # 获取关联的平台账号和运营账号 - platform_account = video.platform_account - operator = platform_account.operator - - # 查找对应的知识库 - knowledge_base = KnowledgeBase.objects.filter( - name__contains=operator.real_name, - type='private' - ).first() - - if knowledge_base and knowledge_base.external_id: - # 创建视频文档 - document_data = { - "name": f"{video.title}_{platform_account.account_name}_视频信息", - "paragraphs": [ - { - "title": "视频基本信息", - "content": f""" - 标题: {video.title} - 平台: {platform_account.get_platform_name_display()} - 账号: {platform_account.account_name} - 视频ID: {video.video_id} - 发布时间: {video.publish_time.strftime('%Y-%m-%d %H:%M:%S') if video.publish_time else '未发布'} - 视频链接: {video.video_url} - 点赞数: {video.likes_count} - 评论数: {video.comments_count} - 分享数: {video.shares_count} - 观看数: {video.views_count} - 视频描述: {video.description or '无'} - """, - "is_active": True - } - ] - } - - # 调用外部API创建文档 - document_id = self._create_document(knowledge_base.external_id, document_data) - - if document_id: - # 创建知识库文档记录 - KnowledgeBaseDocument.objects.create( - knowledge_base=knowledge_base, - document_id=document_id, - document_name=document_data["name"], - external_id=document_id, - uploader_name=request.user.username - ) + # 创建视频 + self.perform_create(serializer) return Response({ "code": 200, - "message": "视频创建成功,并已添加到知识库", - "data": self.get_serializer(video).data + "message": "视频创建成功", + "data": serializer.data }, status=status.HTTP_201_CREATED) def destroy(self, request, *args, **kwargs): - """删除视频记录并更新相关知识库文档""" + """删除视频记录""" video = self.get_object() - - # 获取关联的平台账号和运营账号 - platform_account = video.platform_account - operator = platform_account.operator - - # 查找对应的知识库 - knowledge_base = KnowledgeBase.objects.filter( - name__contains=operator.real_name, - type='private' - ).first() - - if knowledge_base: - # 查找相关文档并标记为删除 - documents = KnowledgeBaseDocument.objects.filter( - knowledge_base=knowledge_base, - document_name__contains=video.title - ) - - for doc in documents: - doc.status = 'deleted' - doc.save() - - # 删除视频记录 self.perform_destroy(video) return Response({ "code": 200, - "message": "视频记录已删除,相关知识库文档已标记为删除", + "message": "视频记录已删除", "data": None }) - def _create_document(self, external_id, doc_data): - """调用外部API创建文档""" - try: - if not external_id: - logger.error("创建文档失败:知识库external_id为空") - return None - - # 在实际应用中,这里需要调用外部API创建文档 - # 模拟创建文档并返回document_id - document_id = str(uuid.uuid4()) - logger.info(f"模拟创建文档成功,document_id: {document_id}") - return document_id - except Exception as e: - logger.error(f"创建文档失败: {str(e)}") - return None - @action(detail=True, methods=['post']) def update_stats(self, request, pk=None): - """更新视频统计数据并同步到知识库""" + """更新视频统计数据""" video = self.get_object() # 获取更新的统计数据 @@ -733,25 +584,6 @@ class VideoViewSet(viewsets.ModelViewSet): setattr(video, field, value) video.save() - # 同步到知识库 - # 在实际应用中应该调用外部API更新文档内容 - platform_account = video.platform_account - operator = platform_account.operator - knowledge_base = KnowledgeBase.objects.filter( - name__contains=operator.real_name, - type='private' - ).first() - - if knowledge_base: - document = KnowledgeBaseDocument.objects.filter( - knowledge_base=knowledge_base, - document_name__contains=video.title, - status='active' - ).first() - - if document: - logger.info(f"应当更新文档 {document.document_id} 的视频统计数据") - return Response({ "code": 200, "message": "视频统计数据更新成功", @@ -793,25 +625,6 @@ class VideoViewSet(viewsets.ModelViewSet): video.publish_time = timezone.now() video.save() - # 同步到知识库 - # 在实际应用中应该调用外部API更新文档内容 - platform_account = video.platform_account - operator = platform_account.operator - knowledge_base = KnowledgeBase.objects.filter( - name__contains=operator.real_name, - type='private' - ).first() - - if knowledge_base: - document = KnowledgeBaseDocument.objects.filter( - knowledge_base=knowledge_base, - document_name__contains=video.title, - status='active' - ).first() - - if document: - logger.info(f"应当更新文档 {document.document_id} 的视频发布状态") - return Response({ "code": 200, "message": "视频已成功发布", @@ -890,31 +703,9 @@ class VideoViewSet(viewsets.ModelViewSet): 'tags': request.data.get('tags', '') } - # 如果提供了计划发布时间,则设置状态为已排期 - scheduled_time = request.data.get('scheduled_time') - if scheduled_time: - from dateutil import parser - try: - parsed_time = parser.parse(scheduled_time) - video_data['scheduled_time'] = parsed_time - video_data['status'] = 'scheduled' - except Exception as e: - return Response({ - "code": 400, - "message": f"计划发布时间格式错误: {str(e)}", - "data": None - }, status=status.HTTP_400_BAD_REQUEST) - # 创建视频记录 video = Video.objects.create(**video_data) - # 添加到知识库 - self._add_to_knowledge_base(video, platform_account) - - # 如果是已排期状态,创建定时任务 - if video.status == 'scheduled': - self._create_publish_task(video) - return Response({ "code": 200, "message": "视频上传成功", @@ -922,7 +713,6 @@ class VideoViewSet(viewsets.ModelViewSet): "id": video.id, "title": video.title, "status": video.get_status_display(), - "scheduled_time": video.scheduled_time } }, status=status.HTTP_201_CREATED) @@ -934,87 +724,6 @@ class VideoViewSet(viewsets.ModelViewSet): "data": None }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - def _add_to_knowledge_base(self, video, platform_account): - """将视频添加到知识库""" - # 获取关联的运营账号 - operator = platform_account.operator - - # 查找对应的知识库 - knowledge_base = KnowledgeBase.objects.filter( - name__contains=operator.real_name, - type='private' - ).first() - - if knowledge_base and knowledge_base.external_id: - # 创建视频文档 - document_data = { - "name": f"{video.title}_{platform_account.account_name}_视频信息", - "paragraphs": [ - { - "title": "视频基本信息", - "content": f""" - 标题: {video.title} - 平台: {platform_account.get_platform_name_display()} - 账号: {platform_account.account_name} - 状态: {video.get_status_display()} - 本地路径: {video.local_path} - 计划发布时间: {video.scheduled_time.strftime('%Y-%m-%d %H:%M:%S') if video.scheduled_time else '未设置'} - 视频描述: {video.description or '无'} - 标签: {video.tags or '无'} - 创建时间: {video.created_at.strftime('%Y-%m-%d %H:%M:%S')} - """, - "is_active": True - } - ] - } - - # 调用外部API创建文档 - document_id = self._create_document(knowledge_base.external_id, document_data) - - if document_id: - # 创建知识库文档记录 - KnowledgeBaseDocument.objects.create( - knowledge_base=knowledge_base, - document_id=document_id, - document_name=document_data["name"], - external_id=document_id, - uploader_name="系统" - ) - - def _create_publish_task(self, video): - """创建定时发布任务""" - try: - from django_celery_beat.models import PeriodicTask, CrontabSchedule - import json - from datetime import datetime - - scheduled_time = video.scheduled_time - - # 创建定时任务 - schedule, _ = CrontabSchedule.objects.get_or_create( - minute=scheduled_time.minute, - hour=scheduled_time.hour, - day_of_month=scheduled_time.day, - month_of_year=scheduled_time.month, - ) - - # 创建周期性任务 - task_name = f"Publish_Video_{video.id}_{datetime.now().timestamp()}" - PeriodicTask.objects.create( - name=task_name, - task='user_management.tasks.publish_scheduled_video', - crontab=schedule, - args=json.dumps([video.id]), - one_off=True, # 只执行一次 - start_time=scheduled_time - ) - - logger.info(f"已创建视频 {video.id} 的定时发布任务,计划发布时间: {scheduled_time}") - - except Exception as e: - logger.error(f"创建定时发布任务失败: {str(e)}") - # 记录错误但不中断流程 - @action(detail=True, methods=['post']) def manual_publish(self, request, pk=None): """手动发布视频""" @@ -1029,51 +738,32 @@ class VideoViewSet(viewsets.ModelViewSet): }, status=status.HTTP_400_BAD_REQUEST) # 检查视频文件是否存在 - if not video.local_path or not os.path.exists(video.local_path): + if video.local_path and not os.path.exists(video.local_path): return Response({ "code": 400, "message": "视频文件不存在,无法发布", "data": None }, status=status.HTTP_400_BAD_REQUEST) - # 自动发布 - 不依赖Celery任务 try: - # 模拟上传到平台 - platform_account = video.platform_account - platform_name = platform_account.platform_name + # 获取视频URL,如果没有提供,则创建一个模拟的URL + video_url = request.data.get('video_url') - # 创建模拟的视频URL和ID - video_url = f"https://example.com/{platform_name}/{video.id}" - video_id = f"VID_{video.id}" + if not video_url: + # 创建模拟的视频URL和ID + platform_account = video.platform_account + platform_name = platform_account.platform_name + video_url = f"https://example.com/{platform_name}/{video.id}" # 更新视频状态 video.status = 'published' video.publish_time = timezone.now() video.video_url = video_url - video.video_id = video_id + video.video_id = f"VID_{video.id}" video.save() logger.info(f"视频 {video.id} 已手动发布") - # 更新知识库文档 - platform_account = video.platform_account - operator = platform_account.operator - - knowledge_base = KnowledgeBase.objects.filter( - name__contains=operator.real_name, - type='private' - ).first() - - if knowledge_base: - document = KnowledgeBaseDocument.objects.filter( - knowledge_base=knowledge_base, - document_name__contains=video.title, - status='active' - ).first() - - if document: - logger.info(f"应当更新文档 {document.document_id} 的视频发布状态") - return Response({ "code": 200, "message": "视频发布成功", diff --git a/apps/template/admin.py b/apps/template/admin.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/template/apps.py b/apps/template/apps.py new file mode 100644 index 0000000..82a8d85 --- /dev/null +++ b/apps/template/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class TemplateConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.template' + verbose_name = '模板管理' diff --git a/apps/template/exceptions.py b/apps/template/exceptions.py new file mode 100644 index 0000000..ebae83d --- /dev/null +++ b/apps/template/exceptions.py @@ -0,0 +1,59 @@ +from rest_framework.views import exception_handler +from rest_framework.exceptions import APIException +from rest_framework import status +from django.http import Http404 +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from rest_framework.response import Response + +def custom_exception_handler(exc, context): + """ + 自定义异常处理器,将所有异常转换为标准响应格式 + + Args: + exc: 异常对象 + context: 异常上下文 + + Returns: + 标准格式的Response对象 + """ + response = exception_handler(exc, context) + + if response is not None: + # 已经被DRF处理的异常,转换为标准格式 + return Response({ + 'code': response.status_code, + 'message': str(exc), + 'data': response.data if hasattr(response, 'data') else None + }, status=response.status_code) + + # 如果是Django的404错误 + if isinstance(exc, Http404): + return Response({ + 'code': status.HTTP_404_NOT_FOUND, + 'message': '请求的资源不存在', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) + + # 如果是验证错误 + if isinstance(exc, ValidationError): + return Response({ + 'code': status.HTTP_400_BAD_REQUEST, + 'message': '数据验证失败', + 'data': str(exc) if str(exc) else '提供的数据无效' + }, status=status.HTTP_400_BAD_REQUEST) + + # 如果是数据库完整性错误(如唯一约束) + if isinstance(exc, IntegrityError): + return Response({ + 'code': status.HTTP_400_BAD_REQUEST, + 'message': '数据库完整性错误', + 'data': str(exc) + }, status=status.HTTP_400_BAD_REQUEST) + + # 其他未处理的异常 + return Response({ + 'code': status.HTTP_500_INTERNAL_SERVER_ERROR, + 'message': '服务器内部错误', + 'data': str(exc) if str(exc) else None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) \ No newline at end of file diff --git a/apps/template/filters.py b/apps/template/filters.py new file mode 100644 index 0000000..e961cb7 --- /dev/null +++ b/apps/template/filters.py @@ -0,0 +1,26 @@ +import django_filters +from .models import Template + +class TemplateFilter(django_filters.FilterSet): + """模板过滤器""" + title = django_filters.CharFilter(field_name='title', lookup_expr='icontains') + content = django_filters.CharFilter(field_name='content', lookup_expr='icontains') + mission = django_filters.CharFilter(field_name='mission') + platform = django_filters.CharFilter(field_name='platform') + collaboration_type = django_filters.CharFilter(field_name='collaboration_type') + service = django_filters.CharFilter(field_name='service') + category = django_filters.NumberFilter(field_name='category__id') + category_name = django_filters.CharFilter(field_name='category__name', lookup_expr='icontains') + created_by = django_filters.NumberFilter(field_name='created_by__id') + is_public = django_filters.BooleanFilter(field_name='is_public') + created_after = django_filters.DateTimeFilter(field_name='created_at', lookup_expr='gte') + created_before = django_filters.DateTimeFilter(field_name='created_at', lookup_expr='lte') + + class Meta: + model = Template + fields = [ + 'title', 'content', 'mission', 'platform', + 'collaboration_type', 'service', 'category', + 'category_name', 'created_by', 'is_public', + 'created_after', 'created_before' + ] \ No newline at end of file diff --git a/apps/template/migrations/0001_initial.py b/apps/template/migrations/0001_initial.py new file mode 100644 index 0000000..24fec55 --- /dev/null +++ b/apps/template/migrations/0001_initial.py @@ -0,0 +1,53 @@ +# Generated by Django 5.2 on 2025-05-19 04:13 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TemplateCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='分类名称')), + ('description', models.TextField(blank=True, null=True, verbose_name='分类描述')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ], + options={ + 'verbose_name': '模板分类', + 'verbose_name_plural': '模板分类', + }, + ), + migrations.CreateModel( + name='Template', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200, verbose_name='模板标题')), + ('content', models.TextField(verbose_name='模板内容')), + ('preview', models.TextField(blank=True, null=True, verbose_name='内容预览')), + ('mission', models.CharField(choices=[('initial_contact', '初步联系'), ('follow_up', '跟进'), ('negotiation', '谈判'), ('closing', '成交'), ('other', '其他')], default='initial_contact', max_length=50, verbose_name='任务类型')), + ('platform', models.CharField(choices=[('tiktok', 'TikTok'), ('instagram', 'Instagram'), ('youtube', 'YouTube'), ('facebook', 'Facebook'), ('twitter', 'Twitter'), ('other', '其他')], default='tiktok', max_length=50, verbose_name='平台')), + ('collaboration_type', models.CharField(choices=[('paid_promotion', '付费推广'), ('affiliate', '联盟营销'), ('sponsored_content', '赞助内容'), ('brand_ambassador', '品牌大使'), ('other', '其他')], default='paid_promotion', max_length=50, verbose_name='合作模式')), + ('service', models.CharField(choices=[('voice', '声优 - 交谈'), ('text', '文本'), ('video', '视频'), ('image', '图片'), ('other', '其他')], default='text', max_length=50, verbose_name='服务类型')), + ('is_public', models.BooleanField(default=True, verbose_name='是否公开')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_templates', to=settings.AUTH_USER_MODEL, verbose_name='创建者')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='templates', to='template.templatecategory', verbose_name='模板分类')), + ], + options={ + 'verbose_name': '模板', + 'verbose_name_plural': '模板', + }, + ), + ] diff --git a/apps/template/migrations/__init__.py b/apps/template/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/template/models.py b/apps/template/models.py new file mode 100644 index 0000000..cb60472 --- /dev/null +++ b/apps/template/models.py @@ -0,0 +1,77 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +class TemplateCategory(models.Model): + """模板分类模型""" + name = models.CharField(_('分类名称'), max_length=100) + description = models.TextField(_('分类描述'), blank=True, null=True) + created_at = models.DateTimeField(_('创建时间'), auto_now_add=True) + updated_at = models.DateTimeField(_('更新时间'), auto_now=True) + + class Meta: + verbose_name = _('模板分类') + verbose_name_plural = _('模板分类') + + def __str__(self): + return self.name + +class Template(models.Model): + """模板模型""" + MISSION_CHOICES = [ + ('initial_contact', '初步联系'), + ('follow_up', '跟进'), + ('negotiation', '谈判'), + ('closing', '成交'), + ('other', '其他'), + ] + + PLATFORM_CHOICES = [ + ('tiktok', 'TikTok'), + ('instagram', 'Instagram'), + ('youtube', 'YouTube'), + ('facebook', 'Facebook'), + ('twitter', 'Twitter'), + ('other', '其他'), + ] + + COLLABORATION_CHOICES = [ + ('paid_promotion', '付费推广'), + ('affiliate', '联盟营销'), + ('sponsored_content', '赞助内容'), + ('brand_ambassador', '品牌大使'), + ('other', '其他'), + ] + + SERVICE_CHOICES = [ + ('voice', '声优 - 交谈'), + ('text', '文本'), + ('video', '视频'), + ('image', '图片'), + ('other', '其他'), + ] + + title = models.CharField(_('模板标题'), max_length=200) + content = models.TextField(_('模板内容')) + preview = models.TextField(_('内容预览'), blank=True, null=True) + category = models.ForeignKey(TemplateCategory, on_delete=models.CASCADE, related_name='templates', verbose_name=_('模板分类')) + mission = models.CharField(_('任务类型'), max_length=50, choices=MISSION_CHOICES, default='initial_contact') + platform = models.CharField(_('平台'), max_length=50, choices=PLATFORM_CHOICES, default='tiktok') + collaboration_type = models.CharField(_('合作模式'), max_length=50, choices=COLLABORATION_CHOICES, default='paid_promotion') + service = models.CharField(_('服务类型'), max_length=50, choices=SERVICE_CHOICES, default='text') + is_public = models.BooleanField(_('是否公开'), default=True) + created_at = models.DateTimeField(_('创建时间'), auto_now_add=True) + updated_at = models.DateTimeField(_('更新时间'), auto_now=True) + + class Meta: + verbose_name = _('模板') + verbose_name_plural = _('模板') + + def __str__(self): + return self.title + + def save(self, *args, **kwargs): + # 自动生成内容预览 + if self.content and not self.preview: + # 截取前100个字符作为预览 + self.preview = self.content[:100] + ('...' if len(self.content) > 100 else '') + super().save(*args, **kwargs) diff --git a/apps/template/pagination.py b/apps/template/pagination.py new file mode 100644 index 0000000..28cc9a1 --- /dev/null +++ b/apps/template/pagination.py @@ -0,0 +1,37 @@ +from rest_framework.pagination import PageNumberPagination +from rest_framework.response import Response + +class StandardResultsSetPagination(PageNumberPagination): + """标准分页器,支持标准响应格式""" + page_size = 10 + page_size_query_param = 'page_size' + max_page_size = 100 + + def get_paginated_response(self, data): + """ + 返回标准格式的分页响应 + + Args: + data: 已经封装为标准格式的响应数据 + """ + # 如果data已经是标准格式{code, message, data},则用data['data']取出实际数据 + actual_data = data.get('data') if isinstance(data, dict) and 'data' in data else data + + # 准备分页元数据 + pagination_data = { + 'count': self.page.paginator.count, + 'next': self.get_next_link(), + 'previous': self.get_previous_link(), + 'results': actual_data, + 'total_pages': self.page.paginator.num_pages, + 'current_page': self.page.number, + } + + # 如果data是标准格式,则保持原有message和code,否则使用默认值 + response_data = { + 'code': data.get('code', 200) if isinstance(data, dict) and 'code' in data else 200, + 'message': data.get('message', '获取数据成功') if isinstance(data, dict) and 'message' in data else '获取数据成功', + 'data': pagination_data + } + + return Response(response_data) \ No newline at end of file diff --git a/apps/template/serializers.py b/apps/template/serializers.py new file mode 100644 index 0000000..8713196 --- /dev/null +++ b/apps/template/serializers.py @@ -0,0 +1,104 @@ +from rest_framework import serializers +from .models import Template, TemplateCategory + +class TemplateCategorySerializer(serializers.ModelSerializer): + """模板分类序列化器""" + class Meta: + model = TemplateCategory + fields = ['id', 'name', 'description', 'created_at', 'updated_at'] + read_only_fields = ['created_at', 'updated_at'] + +class TemplateListSerializer(serializers.ModelSerializer): + """模板列表序列化器(简化版)""" + category_name = serializers.CharField(source='category.name', read_only=True) + mission_display = serializers.CharField(source='get_mission_display', read_only=True) + platform_display = serializers.CharField(source='get_platform_display', read_only=True) + collaboration_type_display = serializers.CharField(source='get_collaboration_type_display', read_only=True) + service_display = serializers.CharField(source='get_service_display', read_only=True) + + class Meta: + model = Template + fields = [ + 'id', 'title', 'preview', 'category_name', + 'mission', 'mission_display', + 'platform', 'platform_display', + 'service', 'service_display', + 'collaboration_type', 'collaboration_type_display', + 'is_public', 'created_at', 'updated_at' + ] + read_only_fields = ['created_at', 'updated_at', 'preview'] + +class TemplateDetailSerializer(serializers.ModelSerializer): + """模板详情序列化器""" + category = TemplateCategorySerializer(read_only=True) + category_id = serializers.IntegerField(write_only=True, required=False) + mission_display = serializers.CharField(source='get_mission_display', read_only=True) + platform_display = serializers.CharField(source='get_platform_display', read_only=True) + collaboration_type_display = serializers.CharField(source='get_collaboration_type_display', read_only=True) + service_display = serializers.CharField(source='get_service_display', read_only=True) + + class Meta: + model = Template + fields = [ + 'id', 'title', 'content', 'preview', + 'category', 'category_id', + 'mission', 'mission_display', + 'platform', 'platform_display', + 'service', 'service_display', + 'collaboration_type', 'collaboration_type_display', + 'is_public', 'created_at', 'updated_at' + ] + read_only_fields = ['created_at', 'updated_at', 'preview'] + + def create(self, validated_data): + """创建模板""" + # 处理category_id字段 + category_id = validated_data.pop('category_id', None) + if category_id: + try: + category = TemplateCategory.objects.get(id=category_id) + validated_data['category'] = category + except TemplateCategory.DoesNotExist: + # 如果分类不存在,创建一个默认分类 + category = TemplateCategory.objects.create(name="默认分类") + validated_data['category'] = category + + return super().create(validated_data) + +class TemplateCreateUpdateSerializer(serializers.ModelSerializer): + """模板创建和更新序列化器""" + class Meta: + model = Template + fields = [ + 'id', 'title', 'content', + 'category', 'mission', 'platform', + 'service', 'collaboration_type', + 'is_public' + ] + read_only_fields = ['preview'] + + def validate(self, data): + """验证数据,处理测试期间可能缺失的字段""" + # 处理category字段,确保有有效的分类 + if 'category' not in data: + # 获取或创建默认分类 + category, created = TemplateCategory.objects.get_or_create(name="默认分类") + data['category'] = category + + # 确保其他必填字段有默认值 + if 'mission' not in data: + data['mission'] = 'initial_contact' + + if 'platform' not in data: + data['platform'] = 'tiktok' + + if 'service' not in data: + data['service'] = 'text' + + if 'collaboration_type' not in data: + data['collaboration_type'] = 'paid_promotion' + + if 'is_public' not in data: + data['is_public'] = True + + return data \ No newline at end of file diff --git a/apps/template/tests.py b/apps/template/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/template/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/template/urls.py b/apps/template/urls.py new file mode 100644 index 0000000..e23de04 --- /dev/null +++ b/apps/template/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import TemplateViewSet, TemplateCategoryViewSet + +router = DefaultRouter() +router.register(r'categories', TemplateCategoryViewSet) +router.register(r'', TemplateViewSet) + +urlpatterns = [ + path('', include(router.urls)), +] \ No newline at end of file diff --git a/apps/template/utils.py b/apps/template/utils.py new file mode 100644 index 0000000..f048c06 --- /dev/null +++ b/apps/template/utils.py @@ -0,0 +1,36 @@ +from rest_framework.response import Response + +class ApiResponse: + """API标准响应格式工具类""" + + @staticmethod + def success(data=None, message="操作成功", code=200): + """ + 返回成功响应 + + Args: + data: 响应数据 + message: 成功消息 + code: 状态码 + """ + return Response({ + "code": code, + "message": message, + "data": data + }) + + @staticmethod + def error(message="操作失败", code=400, data=None): + """ + 返回错误响应 + + Args: + message: 错误消息 + code: 错误状态码 + data: 额外数据 + """ + return Response({ + "code": code, + "message": message, + "data": data + }, status=code) \ No newline at end of file diff --git a/apps/template/views.py b/apps/template/views.py new file mode 100644 index 0000000..b9835f0 --- /dev/null +++ b/apps/template/views.py @@ -0,0 +1,297 @@ +from django.shortcuts import render +from rest_framework import viewsets, permissions, status, filters +from rest_framework.response import Response +from rest_framework.decorators import action +from django_filters.rest_framework import DjangoFilterBackend +from .models import Template, TemplateCategory +from .serializers import ( + TemplateListSerializer, + TemplateDetailSerializer, + TemplateCreateUpdateSerializer, + TemplateCategorySerializer +) +from .filters import TemplateFilter +from .utils import ApiResponse +from .pagination import StandardResultsSetPagination + +# Create your views here. + +class TemplateCategoryViewSet(viewsets.ModelViewSet): + """ + 模板分类视图集 + + 提供模板分类的增删改查功能 + """ + queryset = TemplateCategory.objects.all() + serializer_class = TemplateCategorySerializer + permission_classes = [permissions.AllowAny] # 允许所有人访问 + pagination_class = StandardResultsSetPagination + + def list(self, request, *args, **kwargs): + """获取所有模板分类""" + queryset = self.filter_queryset(self.get_queryset()) + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + serializer = self.get_serializer(queryset, many=True) + return ApiResponse.success( + data=serializer.data, + message="获取模板分类列表成功" + ) + + def retrieve(self, request, *args, **kwargs): + """获取单个模板分类详情""" + instance = self.get_object() + serializer = self.get_serializer(instance) + return ApiResponse.success( + data=serializer.data, + message="获取模板分类详情成功" + ) + + def create(self, request, *args, **kwargs): + """创建模板分类""" + serializer = self.get_serializer(data=request.data) + if serializer.is_valid(): + self.perform_create(serializer) + return ApiResponse.success( + data=serializer.data, + message="模板分类创建成功", + code=status.HTTP_201_CREATED + ) + return ApiResponse.error( + message="模板分类创建失败", + data=serializer.errors, + code=status.HTTP_400_BAD_REQUEST + ) + + def update(self, request, *args, **kwargs): + """更新模板分类""" + partial = kwargs.pop('partial', False) + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=partial) + if serializer.is_valid(): + self.perform_update(serializer) + return ApiResponse.success( + data=serializer.data, + message="模板分类更新成功" + ) + return ApiResponse.error( + message="模板分类更新失败", + data=serializer.errors, + code=status.HTTP_400_BAD_REQUEST + ) + + def destroy(self, request, *args, **kwargs): + """删除模板分类""" + instance = self.get_object() + self.perform_destroy(instance) + return ApiResponse.success( + data=None, + message="模板分类删除成功" + ) + +class TemplateViewSet(viewsets.ModelViewSet): + """ + 模板视图集 + + 提供模板的增删改查功能 + """ + queryset = Template.objects.all() + permission_classes = [permissions.AllowAny] # 允许所有人访问 + filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] + filterset_class = TemplateFilter + search_fields = ['title', 'content'] + ordering_fields = ['created_at', 'updated_at', 'title'] + ordering = ['-created_at'] + pagination_class = StandardResultsSetPagination + + def get_queryset(self): + """ + 自定义查询集,返回所有模板 + """ + return Template.objects.all() + + def get_serializer_class(self): + """根据不同的操作返回不同的序列化器""" + if self.action == 'list': + return TemplateListSerializer + elif self.action in ['create', 'update', 'partial_update']: + return TemplateCreateUpdateSerializer + return TemplateDetailSerializer + + def list(self, request, *args, **kwargs): + """获取所有模板""" + queryset = self.filter_queryset(self.get_queryset()) + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + serializer = self.get_serializer(queryset, many=True) + return ApiResponse.success( + data=serializer.data, + message="获取模板列表成功" + ) + + def retrieve(self, request, *args, **kwargs): + """获取单个模板详情""" + instance = self.get_object() + serializer = self.get_serializer(instance) + return ApiResponse.success( + data=serializer.data, + message="获取模板详情成功" + ) + + def create(self, request, *args, **kwargs): + """创建模板""" + serializer = self.get_serializer(data=request.data) + if serializer.is_valid(): + self.perform_create(serializer) + return ApiResponse.success( + data=serializer.data, + message="模板创建成功", + code=status.HTTP_201_CREATED + ) + return ApiResponse.error( + message="模板创建失败", + data=serializer.errors, + code=status.HTTP_400_BAD_REQUEST + ) + + def update(self, request, *args, **kwargs): + """更新模板""" + partial = kwargs.pop('partial', False) + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=partial) + if serializer.is_valid(): + self.perform_update(serializer) + return ApiResponse.success( + data=serializer.data, + message="模板更新成功" + ) + return ApiResponse.error( + message="模板更新失败", + data=serializer.errors, + code=status.HTTP_400_BAD_REQUEST + ) + + def destroy(self, request, *args, **kwargs): + """删除模板""" + instance = self.get_object() + self.perform_destroy(instance) + return ApiResponse.success( + data=None, + message="模板删除成功" + ) + + @action(detail=False, methods=['get']) + def mine(self, request): + """获取所有模板""" + templates = Template.objects.all() + page = self.paginate_queryset(templates) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + serializer = self.get_serializer(templates, many=True) + return ApiResponse.success( + data=serializer.data, + message="获取模板成功" + ) + + @action(detail=False, methods=['get']) + def public(self, request): + """获取所有公开的模板""" + templates = Template.objects.filter(is_public=True) + page = self.paginate_queryset(templates) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + serializer = self.get_serializer(templates, many=True) + return ApiResponse.success( + data=serializer.data, + message="获取公开模板成功" + ) + + @action(detail=False, methods=['get']) + def by_mission(self, request): + """按任务类型获取模板""" + mission = request.query_params.get('mission', None) + if not mission: + return ApiResponse.error( + message="需要提供mission参数", + code=status.HTTP_400_BAD_REQUEST + ) + + queryset = self.get_queryset().filter(mission=mission) + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + serializer = self.get_serializer(queryset, many=True) + return ApiResponse.success( + data=serializer.data, + message="按任务类型获取模板成功" + ) + + @action(detail=False, methods=['get']) + def by_platform(self, request): + """按平台获取模板""" + platform = request.query_params.get('platform', None) + if not platform: + return ApiResponse.error( + message="需要提供platform参数", + code=status.HTTP_400_BAD_REQUEST + ) + + queryset = self.get_queryset().filter(platform=platform) + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + serializer = self.get_serializer(queryset, many=True) + return ApiResponse.success( + data=serializer.data, + message="按平台获取模板成功" + ) + + @action(detail=False, methods=['get']) + def by_collaboration(self, request): + """按合作模式获取模板""" + collaboration_type = request.query_params.get('collaboration_type', None) + if not collaboration_type: + return ApiResponse.error( + message="需要提供collaboration_type参数", + code=status.HTTP_400_BAD_REQUEST + ) + + queryset = self.get_queryset().filter(collaboration_type=collaboration_type) + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + serializer = self.get_serializer(queryset, many=True) + return ApiResponse.success( + data=serializer.data, + message="按合作模式获取模板成功" + ) + + @action(detail=False, methods=['get']) + def by_service(self, request): + """按服务类型获取模板""" + service = request.query_params.get('service', None) + if not service: + return ApiResponse.error( + message="需要提供service参数", + code=status.HTTP_400_BAD_REQUEST + ) + + queryset = self.get_queryset().filter(service=service) + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + serializer = self.get_serializer(queryset, many=True) + return ApiResponse.success( + data=serializer.data, + message="按服务类型获取模板成功" + ) diff --git a/daren_project/settings.py b/daren_project/settings.py index 5ad3ebd..c0ba028 100644 --- a/daren_project/settings.py +++ b/daren_project/settings.py @@ -28,7 +28,7 @@ SECRET_KEY = 'django-insecure-aie+z75u&tnnx8@g!2ie+q)qhq1!eg&ob!c1(e1vr!eclh+xv6 # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = ['localhost', '127.0.0.1', '02bf-180-159-100-165.ngrok-free.app'] +ALLOWED_HOSTS = ['localhost', '127.0.0.1', '325a-180-159-100-165.ngrok-free.app'] # Application definition @@ -43,6 +43,7 @@ INSTALLED_APPS = [ 'rest_framework.authtoken', 'rest_framework', # Django REST Framework 'channels', # WebSocket 支持 + 'django_filters', # 添加django-filter支持 'apps.accounts', 'apps.knowledge_base', 'apps.chat', @@ -53,6 +54,8 @@ INSTALLED_APPS = [ 'apps.common', 'apps.brands', 'apps.operation', + 'apps.discovery', # 新添加的Discovery应用 + 'apps.template', # 新添加的Template应用 ] MIDDLEWARE = [ @@ -150,13 +153,14 @@ REST_FRAMEWORK = { # 'rest_framework.authentication.SessionAuthentication', ], 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.AllowAny', + 'rest_framework.permissions.AllowAny', # 允许所有人访问,用于测试 ], 'DEFAULT_PARSER_CLASSES': [ 'rest_framework.parsers.JSONParser', 'rest_framework.parsers.FormParser', 'rest_framework.parsers.MultiPartParser' ], + 'EXCEPTION_HANDLER': 'apps.template.exceptions.custom_exception_handler', } @@ -176,7 +180,7 @@ AUTH_USER_MODEL = 'accounts.User' API_BASE_URL = 'http://81.69.223.133:48329' SILICON_CLOUD_API_KEY = 'sk-xqbujijjqqmlmlvkhvxeogqjtzslnhdtqxqgiyuhwpoqcjvf' -GMAIL_WEBHOOK_URL = 'https://02bf-180-159-100-165.ngrok-free.app/api/gmail/webhook/' +GMAIL_WEBHOOK_URL = 'https://325a-180-159-100-165.ngrok-free.app/api/gmail/webhook/' APPLICATION_ID = 'd5d11efa-ea9a-11ef-9933-0242ac120006' @@ -195,3 +199,8 @@ GMAIL_PUBSUB_TOPIC = 'gmail-watch-topic' # 设置允许使用Google Pub/Sub的应用列表 INSTALLED_APPS += ['google.cloud.pubsub'] +FEISHU_APP_ID = "cli_a5c97daacb9e500d" +FEISHU_APP_SECRET = "fdVeOCLXmuIHZVmSV0VbJh9wd0Kq1o5y" +FEISHU_DEFAULT_APP_TOKEN = "XYE6bMQUOaZ5y5svj4vcWohGnmg" +FEISHU_DEFAULT_ACCESS_TOKEN = "u-fK0HvbXVte.G2xzYs5oxV6k1nHu1glvFgG00l0Ma24VD" + diff --git a/daren_project/urls.py b/daren_project/urls.py index 4f46ab1..cc95320 100644 --- a/daren_project/urls.py +++ b/daren_project/urls.py @@ -1,4 +1,4 @@ -""" +""" URL configuration for daren_project project. The `urlpatterns` list routes URLs to views. For more information please see: @@ -25,7 +25,9 @@ urlpatterns = [ path('api/permissions/', include('apps.permissions.urls')), path('api/notification/', include('apps.notification.urls')), path('api/gmail/', include('apps.gmail.urls')), - # path('api/feishu/', include('apps.feishu.urls')), + path('api/feishu/', include('apps.feishu.urls')), path('api/', include('apps.brands.urls')), path('api/operation/', include('apps.operation.urls')), + path('api/discovery/', include('apps.discovery.urls')), + path('api/templates/', include('apps.template.urls')), ] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 449bddb5a9cb66335e06b0504e28e671000840bd..b486196250c37c9255546f4fa7a9cde61b3e4a38 100644 GIT binary patch literal 4108 zcma)oom+HcoG7v` zw&{ADsybEG{`;?KS(UCV%e3tDdMY>Z%=CS&*P;BV*QA`4_j<&d&QTr zyguDaf2FLf2CV1OM8bHDX0G#;a80io957|87oJ>K>sAb}C9b0ml?5rCaUo59iEJdh zoxYJPvhf_T5D68uCi$Aj>Ac)W6s{~4etk9y?k$uHs!NqYjy39AnOLoj#I?0#Vb8W< zt+Q}!9eyF-S~M@_^0Lu-pO$J;byd2`8n!Bj`opM8;E(ht$ z$cdu?-C{VUI+-<2&5loj7u2w+*SncUbY_|}_^EseRCkI1E0sZu=42FnJmv!4V2ktc zS{~1bsPb*ZK}8-TORgijV0o*Xny2+#N#;{(?#17ceC;Auay(a6*LvT-RBI9w!5Y}@ zf^Az#F|RxeKvV6Kk51g6!$z{TlO$PyvCcPHbpJc!r!|9piX(w^0Lnh9a@5q)Ri-!wbLX@Ay5Gx>5VMxvqH7K&`A_nC6Z zU-X=vI;UHo&gNmr=SE%Q?Chd4%-Fg^t|H>Zy_X**d24N?IO)7m`BP{!+Ho^M=p^)V zgAmhI!@l+$Cc&Xzk+di(SC`Uk{Z!5@XcNgw7}>*Q%NU(u_Tx_ZXLA_Cwf@7jo=StPScfTG?+DD# zc|?vZ+-9Pi1x!I^g!zNyt?Z1-6=U)^hA+yzC#JU`8v-fs1n?T|DY-fcy!kx+ z?8)_$yBwbN5WeLqq1zflr~R|>q)p_g>OCH zNUok2wKe&&ND7GR|)G@Xkc3 z=v-~2oi!xB3Xks?+1H>ax9+B68Taq;e)C+-+`QFEe$M2J{-rMN>Z-F72iRt0Eji$u zLB@3u12};hFwOPi9=uo2&_UcXn9A8d{a`C^80pP-=Lh*pWRCvBQAhAw%NxQRsrNLP z&1~e>?>iq7wU-Xc?D~ZvW}ok@0@u%)T}?tFWdhtA|Mmoj+vXTW8K960D$~qg8%>k delta 85 zcmV-b0IL6tAczi-ppn+Flk5S^lX?SmlNJP2lcoeNlLiGQlVArTlja9LlUfKUlUxaA rleP*blPC*plfVl`lY|T)lg