获取推送的gmail消息
This commit is contained in:
parent
aeebe645bf
commit
0bcd8822dc
16
apps/accounts/migrations/0006_delete_usergoal.py
Normal file
16
apps/accounts/migrations/0006_delete_usergoal.py
Normal file
@ -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',
|
||||
),
|
||||
]
|
@ -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}的目标"
|
||||
|
@ -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
|
@ -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)
|
@ -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/<str:pk>/', user_detail, name='user_detail'),
|
||||
path('users/<str:pk>/update/', user_update, name='user_update'),
|
||||
path('users/<str:pk>/delete/', user_delete, name='user_delete'),
|
||||
path('goals/', UserGoalView.as_view(), name='user_goals'),
|
||||
path('goals/<str:goal_id>/', UserGoalDetailView.as_view(), name='user_goal_detail'),
|
||||
path('recommended-reply/', RecommendedReplyView.as_view(), name='recommended_reply'),
|
||||
]
|
||||
|
@ -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)
|
||||
|
@ -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())
|
||||
|
18
apps/chat/migrations/0002_alter_chathistory_content.py
Normal file
18
apps/chat/migrations/0002_alter_chathistory_content.py
Normal file
@ -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),
|
||||
),
|
||||
]
|
329
apps/discovery/README.md
Normal file
329
apps/discovery/README.md
Normal file
@ -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`中提供
|
||||
- 接口不需要认证,可直接访问
|
3
apps/discovery/__init__.py
Normal file
3
apps/discovery/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""
|
||||
Discovery app for creator discovery and search.
|
||||
"""
|
18
apps/discovery/admin.py
Normal file
18
apps/discovery/admin.py
Normal file
@ -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')
|
7
apps/discovery/apps.py
Normal file
7
apps/discovery/apps.py
Normal file
@ -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'
|
31
apps/discovery/exceptions.py
Normal file
31
apps/discovery/exceptions.py
Normal file
@ -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)
|
3
apps/discovery/management/__init__.py
Normal file
3
apps/discovery/management/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""
|
||||
Discovery app management commands.
|
||||
"""
|
3
apps/discovery/management/commands/__init__.py
Normal file
3
apps/discovery/management/commands/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""
|
||||
Discovery app management commands.
|
||||
"""
|
@ -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('成功创建所有模拟数据!'))
|
57
apps/discovery/migrations/0001_initial.py
Normal file
57
apps/discovery/migrations/0001_initial.py
Normal file
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
@ -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='创建日期'),
|
||||
),
|
||||
]
|
0
apps/discovery/migrations/__init__.py
Normal file
0
apps/discovery/migrations/__init__.py
Normal file
79
apps/discovery/models.py
Normal file
79
apps/discovery/models.py
Normal file
@ -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
|
22
apps/discovery/pagination.py
Normal file
22
apps/discovery/pagination.py
Normal file
@ -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
|
||||
}
|
||||
})
|
35
apps/discovery/serializers.py
Normal file
35
apps/discovery/serializers.py
Normal file
@ -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__'
|
3
apps/discovery/tests.py
Normal file
3
apps/discovery/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
12
apps/discovery/urls.py
Normal file
12
apps/discovery/urls.py
Normal file
@ -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)),
|
||||
]
|
276
apps/discovery/views.py
Normal file
276
apps/discovery/views.py
Normal file
@ -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
|
39
apps/feishu/migrations/0002_feishuauth.py
Normal file
39
apps/feishu/migrations/0002_feishuauth.py
Normal file
@ -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',
|
||||
},
|
||||
),
|
||||
]
|
@ -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',
|
||||
),
|
||||
]
|
@ -53,3 +53,25 @@ class FeishuCreator(models.Model):
|
||||
db_table = 'feishu_creators'
|
||||
verbose_name = '创作者数据'
|
||||
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})"
|
@ -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)
|
923
apps/feishu/services/auto_gmail_conversation_service.py
Normal file
923
apps/feishu/services/auto_gmail_conversation_service.py
Normal file
@ -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
|
216
apps/feishu/services/bitable_service.py
Normal file
216
apps/feishu/services/bitable_service.py
Normal file
@ -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": {}}
|
||||
]
|
346
apps/feishu/services/data_sync_service.py
Normal file
346
apps/feishu/services/data_sync_service.py
Normal file
@ -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)
|
||||
}
|
1
apps/feishu/services/feishu_service.py
Normal file
1
apps/feishu/services/feishu_service.py
Normal file
@ -0,0 +1 @@
|
||||
|
153
apps/feishu/services/gmail_extraction_service.py
Normal file
153
apps/feishu/services/gmail_extraction_service.py
Normal file
@ -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)}"
|
@ -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/<int:pk>/', 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'),
|
||||
|
||||
]
|
@ -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='对接人')
|
||||
logger = logging.getLogger(__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')
|
||||
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')
|
||||
|
||||
# 账号属性和报价
|
||||
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='回复速度')
|
||||
if not table_url or not access_token:
|
||||
return Response(
|
||||
{"error": "请提供多维表格URL和access_token"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# 合作相关
|
||||
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?')
|
||||
try:
|
||||
# 从URL中提取app_token和table_id
|
||||
app_token, table_id = BitableService.extract_params_from_url(table_url)
|
||||
|
||||
# 渠道和进度
|
||||
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='合作品牌')
|
||||
# 先获取一些样本数据,检查我们能否访问多维表格
|
||||
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
|
||||
)
|
||||
|
||||
# 品类信息
|
||||
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='达人标想要货品类')
|
||||
return Response(sample_data, status=status.HTTP_200_OK)
|
||||
|
||||
# 其他信息
|
||||
creator_base = models.CharField(max_length=100, blank=True, verbose_name='达人base')
|
||||
notes = models.TextField(blank=True, verbose_name='父记录')
|
||||
except ValueError as ve:
|
||||
return Response(
|
||||
{"error": str(ve), "details": "URL格式可能不正确"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
db_table = 'feishu_creators'
|
||||
verbose_name = '创作者数据'
|
||||
verbose_name_plural = '创作者数据'
|
34
apps/gmail/migrations/0005_usergoal.py
Normal file
34
apps/gmail/migrations/0005_usergoal.py
Normal file
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
@ -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='目标状态'),
|
||||
),
|
||||
]
|
@ -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),
|
||||
),
|
||||
]
|
@ -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附件的唯一标识符,可能很长'),
|
||||
),
|
||||
]
|
@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
@ -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类型")
|
||||
@ -108,3 +109,77 @@ class ConversationSummary(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
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'])
|
@ -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']
|
||||
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
|
||||
|
||||
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):
|
||||
"""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')
|
||||
"""验证用户邮箱是否已授权"""
|
||||
user = self.context['request'].user
|
||||
user_email = data.get('user_email')
|
||||
|
||||
# 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.")
|
||||
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
|
||||
|
||||
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
|
||||
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
|
@ -274,102 +274,7 @@ 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
|
||||
if not service:
|
||||
logger.error(f"获取Gmail服务失败: {user_email}")
|
||||
return []
|
||||
|
||||
logger.info(f"找到 {len(influencer_emails)} 个活跃的Gmail对话")
|
||||
logger.info(f"开始处理新邮件: 用户 {user.username}, 邮箱 {user_email}")
|
||||
|
||||
# 方法1: 通过历史记录获取变更
|
||||
# 获取历史记录(如果没有历史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:
|
||||
logger.info(f"通过历史记录获取变更...")
|
||||
# 获取历史记录变更,包含所有相关变更类型
|
||||
history_results = service.users().history().list(
|
||||
history_list = service.users().history().list(
|
||||
userId='me',
|
||||
startHistoryId=history_id,
|
||||
historyTypes=['messageAdded', 'messageDeleted', 'labelAdded', 'labelRemoved']
|
||||
historyTypes=['messageAdded']
|
||||
).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}")
|
||||
# 提取所有新增邮件ID
|
||||
message_ids = []
|
||||
|
||||
# 处理历史记录
|
||||
processed_by_history = False
|
||||
if 'history' in history_results and history_results['history']:
|
||||
logger.info(f"找到 {len(history_results.get('history', []))} 条历史变更记录")
|
||||
# 处理历史变更
|
||||
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)
|
||||
|
||||
# 提取所有消息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'])
|
||||
logger.info(f"从历史变更中获取到 {len(message_ids)} 封新邮件")
|
||||
|
||||
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']
|
||||
).execute()
|
||||
# 获取邮件完整内容
|
||||
for msg_id in message_ids:
|
||||
try:
|
||||
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)}")
|
||||
|
||||
# 如果通过历史ID没有找到新邮件,直接获取最新邮件
|
||||
if not messages_found:
|
||||
logger.info("通过历史ID没有找到新邮件,直接获取最新邮件...")
|
||||
# 获取收件箱最新邮件
|
||||
results = service.users().messages().list(userId='me', labelIds=['INBOX'], maxResults=10).execute()
|
||||
messages = results.get('messages', [])
|
||||
if not messages:
|
||||
logger.info(f"未找到任何收件箱邮件")
|
||||
return
|
||||
|
||||
logger.info(f"找到 {len(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)}")
|
||||
|
||||
# 检查所有邮件,不限制处理数量
|
||||
processed_message_ids = []
|
||||
saved_count = 0
|
||||
except Exception as he:
|
||||
logger.error(f"获取历史变更失败: {str(he)}")
|
||||
# 历史获取失败,退回到获取最新邮件
|
||||
logger.info("尝试直接获取最新邮件...")
|
||||
|
||||
# 从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', []))
|
||||
# 获取收件箱最新邮件
|
||||
results = service.users().messages().list(userId='me', labelIds=['INBOX'], maxResults=10).execute()
|
||||
messages = results.get('messages', [])
|
||||
|
||||
for msg in messages:
|
||||
message_id = msg['id']
|
||||
# 获取每封邮件的详细信息
|
||||
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,直接获取最新邮件...")
|
||||
|
||||
# 避免重复处理
|
||||
if message_id in already_processed_ids:
|
||||
logger.info(f"邮件ID: {message_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
|
||||
|
||||
processed_message_ids.append(message_id)
|
||||
logger.info(f"处理新发现的邮件ID: {message_id}")
|
||||
# 使用单独的方法处理单个邮件
|
||||
success = GmailService._process_single_email(user, credential, message, msg_id, user_email)
|
||||
|
||||
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)}")
|
||||
if success:
|
||||
processed_ids.append(msg_id)
|
||||
logger.info(f"成功处理邮件: {msg_id}")
|
||||
else:
|
||||
logger.error(f"获取历史变更失败: {str(e)}")
|
||||
logger.warning(f"邮件处理失败或未满足处理条件: {msg_id}")
|
||||
except Exception as msg_error:
|
||||
logger.error(f"处理邮件失败: {str(msg_error)}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
logger.info(f"成功处理 {len(processed_ids)} 封新邮件")
|
||||
return processed_ids
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理Gmail新消息失败: {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_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)
|
||||
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}")
|
||||
|
||||
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
|
||||
|
||||
# 发送通知
|
||||
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}")
|
||||
|
||||
# 将新邮件保存到聊天历史
|
||||
success = GmailService._save_email_to_chat(
|
||||
user,
|
||||
credential,
|
||||
conversation,
|
||||
email_data
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f"成功保存邮件到聊天历史")
|
||||
saved = True
|
||||
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)}")
|
||||
|
||||
# 场景3: 其他邮件 - 不保存非达人相关邮件
|
||||
else:
|
||||
logger.info(f"邮件 {email_data['from_email']} → {email_data['to_email']} 与跟踪的达人对话无关,不保存")
|
||||
|
||||
return saved
|
||||
|
||||
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 False
|
||||
|
||||
@staticmethod
|
||||
@ -1063,13 +894,14 @@ 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:
|
||||
# 创建聊天消息 - 直接使用邮件正文,不添加主题前缀
|
||||
content = body
|
||||
|
||||
# 创建聊天消息
|
||||
chat_message = ChatHistory.objects.create(
|
||||
user=user,
|
||||
@ -1077,12 +909,13 @@ class GmailService:
|
||||
conversation_id=conversation.conversation_id,
|
||||
title=conversation.title,
|
||||
role='user',
|
||||
content=f"主题: {subject}\n\n{body}",
|
||||
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'
|
||||
}
|
||||
)
|
||||
@ -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>"或纯"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 ""
|
||||
|
||||
|
||||
|
217
apps/gmail/services/goal_service.py
Normal file
217
apps/gmail/services/goal_service.py
Normal file
@ -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
|
||||
|
@ -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/<int:pk>/', GmailCredentialDetailView.as_view(), name='credential_detail'),
|
||||
path('conversations/', GmailConversationView.as_view(), name='conversation_list'),
|
||||
path('attachments/', GmailAttachmentListView.as_view(), name='attachment_list'),
|
||||
path('attachments/<str:conversation_id>/', 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/<str:conversation_id>/', 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)),
|
||||
]
|
@ -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] # 限制访问,仅允许已认证用户
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = GmailCredentialSerializer
|
||||
|
||||
def get(self, request):
|
||||
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 凭证列表。
|
||||
列出用户的Gmail账号凭证
|
||||
|
||||
Args:
|
||||
request: Django REST Framework 请求对象。
|
||||
|
||||
Returns:
|
||||
Response: 包含凭证列表的 JSON 响应。
|
||||
|
||||
Status Codes:
|
||||
200: 成功返回凭证列表。
|
||||
返回当前用户所有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):
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
"""
|
||||
API 视图,用于管理特定 Gmail 凭证的获取、更新和删除。
|
||||
获取特定Gmail凭证的详细信息
|
||||
|
||||
根据ID返回单个Gmail账号凭证的详细信息。
|
||||
"""
|
||||
permission_classes = [IsAuthenticated] # 限制访问,仅允许已认证用户
|
||||
|
||||
def get(self, request, pk):
|
||||
"""
|
||||
处理 GET 请求,获取特定 Gmail 凭证的详细信息。
|
||||
|
||||
Args:
|
||||
request: Django REST Framework 请求对象。
|
||||
pk: 凭证的主键 ID。
|
||||
|
||||
Returns:
|
||||
Response: 包含凭证详细信息的 JSON 响应。
|
||||
|
||||
Status Codes:
|
||||
200: 成功返回凭证信息。
|
||||
404: 未找到指定凭证。
|
||||
"""
|
||||
# 获取用户拥有的指定凭证,未找到则返回 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 凭证(如设置为默认凭证)。
|
||||
创建Gmail账号凭证 - 初始化或完成OAuth授权
|
||||
|
||||
Args:
|
||||
request: Django REST Framework 请求对象,包含更新数据。
|
||||
pk: 凭证的主键 ID。
|
||||
此接口有两个功能:
|
||||
1. 如果未提供auth_code,则初始化OAuth并返回授权URL
|
||||
2. 如果提供了auth_code,则完成OAuth并保存凭证
|
||||
|
||||
Returns:
|
||||
Response: 包含更新后凭证数据的 JSON 响应,或错误信息。
|
||||
|
||||
Status Codes:
|
||||
200: 成功更新凭证。
|
||||
400: 请求数据无效。
|
||||
404: 未找到指定凭证。
|
||||
请求参数:
|
||||
- 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)
|
||||
# 返回无效数据错误
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"更新Gmail凭证失败: {str(e)}")
|
||||
return Response({
|
||||
'code': 400,
|
||||
'message': '请求数据无效',
|
||||
'data': serializer.errors
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, pk):
|
||||
"""
|
||||
处理 DELETE 请求,删除特定 Gmail 凭证。
|
||||
|
||||
Args:
|
||||
request: Django REST Framework 请求对象。
|
||||
pk: 凭证的主键 ID。
|
||||
|
||||
Returns:
|
||||
Response: 空响应,表示删除成功。
|
||||
|
||||
Status Codes:
|
||||
204: 成功删除凭证。
|
||||
404: 未找到指定凭证。
|
||||
"""
|
||||
# 获取并删除用户拥有的指定凭证
|
||||
credential = get_object_or_404(GmailCredential, pk=pk, user=request.user)
|
||||
credential.delete()
|
||||
return Response({
|
||||
'code': 204,
|
||||
'message': '凭证已成功删除',
|
||||
'code': 500,
|
||||
'message': f'更新Gmail凭证失败: {str(e)}',
|
||||
'data': None
|
||||
}, status=status.HTTP_204_NO_CONTENT)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
"""
|
||||
部分更新Gmail凭证信息
|
||||
|
||||
更新Gmail凭证的部分字段。
|
||||
如果设置is_default=True,会自动将其他凭证设为非默认。
|
||||
"""
|
||||
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):
|
||||
@ -660,22 +780,37 @@ class GmailWebhookView(APIView):
|
||||
|
||||
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()
|
||||
else:
|
||||
logger.warning(f"收到推送通知,但未找到对应的Gmail凭证: {email_address}")
|
||||
# 获取并保存最新邮件
|
||||
print("[Gmail Webhook] 开始获取最新邮件...")
|
||||
|
||||
# 确认接收
|
||||
return Response({
|
||||
'code': 200,
|
||||
'message': '成功接收推送通知',
|
||||
'data': None
|
||||
})
|
||||
# 处理新邮件,使用静态方法而不是实例化类
|
||||
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}")
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
527
apps/operation/README.md
Normal file
527
apps/operation/README.md
Normal file
@ -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 | 更新时间 |
|
||||
</rewritten_file>
|
@ -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'
|
||||
|
@ -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='发布账号')),
|
||||
|
@ -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'),
|
||||
),
|
||||
]
|
@ -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 = [
|
||||
@ -41,49 +38,52 @@ class OperatorAccount(models.Model):
|
||||
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,7 +109,7 @@ 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='更新时间')
|
||||
|
||||
@ -118,9 +118,4 @@ class Video(models.Model):
|
||||
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})"
|
||||
|
@ -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": "获取数据成功",
|
||||
|
@ -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,8 +46,95 @@ 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):
|
||||
platform_account_name = serializers.CharField(source='platform_account.account_name', read_only=True)
|
||||
@ -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:
|
||||
# 尝试获取对应的平台账号对象
|
||||
@ -85,18 +170,10 @@ class VideoSerializer(serializers.ModelSerializer):
|
||||
|
||||
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 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']
|
@ -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,119 +74,29 @@ 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
|
||||
)
|
||||
self.perform_create(serializer)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
"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
|
||||
# 处理响应数据
|
||||
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")
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# 调用外部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
|
||||
)
|
||||
# 添加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:
|
||||
@ -471,6 +411,10 @@ class PlatformAccountViewSet(viewsets.ModelViewSet):
|
||||
"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({
|
||||
"code": 400,
|
||||
@ -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:
|
||||
# 模拟上传到平台
|
||||
# 获取视频URL,如果没有提供,则创建一个模拟的URL
|
||||
video_url = request.data.get('video_url')
|
||||
|
||||
if not video_url:
|
||||
# 创建模拟的视频URL和ID
|
||||
platform_account = video.platform_account
|
||||
platform_name = platform_account.platform_name
|
||||
|
||||
# 创建模拟的视频URL和ID
|
||||
video_url = f"https://example.com/{platform_name}/{video.id}"
|
||||
video_id = f"VID_{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": "视频发布成功",
|
||||
|
0
apps/template/admin.py
Normal file
0
apps/template/admin.py
Normal file
7
apps/template/apps.py
Normal file
7
apps/template/apps.py
Normal file
@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TemplateConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.template'
|
||||
verbose_name = '模板管理'
|
59
apps/template/exceptions.py
Normal file
59
apps/template/exceptions.py
Normal file
@ -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)
|
26
apps/template/filters.py
Normal file
26
apps/template/filters.py
Normal file
@ -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'
|
||||
]
|
53
apps/template/migrations/0001_initial.py
Normal file
53
apps/template/migrations/0001_initial.py
Normal file
@ -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': '模板',
|
||||
},
|
||||
),
|
||||
]
|
0
apps/template/migrations/__init__.py
Normal file
0
apps/template/migrations/__init__.py
Normal file
77
apps/template/models.py
Normal file
77
apps/template/models.py
Normal file
@ -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)
|
37
apps/template/pagination.py
Normal file
37
apps/template/pagination.py
Normal file
@ -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)
|
104
apps/template/serializers.py
Normal file
104
apps/template/serializers.py
Normal file
@ -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
|
3
apps/template/tests.py
Normal file
3
apps/template/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
11
apps/template/urls.py
Normal file
11
apps/template/urls.py
Normal file
@ -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)),
|
||||
]
|
36
apps/template/utils.py
Normal file
36
apps/template/utils.py
Normal file
@ -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)
|
297
apps/template/views.py
Normal file
297
apps/template/views.py
Normal file
@ -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="按服务类型获取模板成功"
|
||||
)
|
@ -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"
|
||||
|
||||
|
@ -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')),
|
||||
]
|
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
Loading…
Reference in New Issue
Block a user