获取推送的gmail消息

This commit is contained in:
wanjia 2025-05-20 15:57:10 +08:00
parent aeebe645bf
commit 0bcd8822dc
71 changed files with 6660 additions and 1767 deletions

View 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',
),
]

View File

@ -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}的目标"

View File

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

View File

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

View File

@ -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'),
]

View File

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

View File

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

View 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
View 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`中提供
- 接口不需要认证,可直接访问

View File

@ -0,0 +1,3 @@
"""
Discovery app for creator discovery and search.
"""

18
apps/discovery/admin.py Normal file
View 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
View 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'

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

View File

@ -0,0 +1,3 @@
"""
Discovery app management commands.
"""

View File

@ -0,0 +1,3 @@
"""
Discovery app management commands.
"""

View File

@ -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('成功创建所有模拟数据!'))

View 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'],
},
),
]

View File

@ -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='创建日期'),
),
]

View File

79
apps/discovery/models.py Normal file
View 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

View 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
}
})

View 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
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

12
apps/discovery/urls.py Normal file
View 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
View 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

View 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',
},
),
]

View File

@ -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',
),
]

View File

@ -52,4 +52,26 @@ class FeishuCreator(models.Model):
class Meta:
db_table = 'feishu_creators'
verbose_name = '创作者数据'
verbose_name_plural = '创作者数据'
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})"

View File

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

View 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 Shopcurrently 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

View 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": {}}
]

View 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)
}

View File

@ -0,0 +1 @@

View 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)}"

View File

@ -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'),
]

View File

@ -1,55 +1,418 @@
# apps/feishu/models.py
from django.db import models
from django.utils import timezone
import json
import traceback
import logging
import uuid
from django.db import transaction
from django.utils import timezone
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from django.http import Http404
from .models import FeishuTableMapping
from .services.bitable_service import BitableService
from .services.data_sync_service import DataSyncService
from .services.gmail_extraction_service import GmailExtractionService
from .services.auto_gmail_conversation_service import AutoGmailConversationService
from rest_framework.permissions import IsAuthenticated
from apps.gmail.models import GmailCredential, GmailConversation, AutoReplyConfig
from apps.gmail.services.gmail_service import GmailService
from apps.gmail.serializers import AutoReplyConfigSerializer
class FeishuCreator(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
record_id = models.CharField(max_length=100, unique=True, verbose_name='飞书记录ID')
# 对接人信息
contact_person = models.CharField(max_length=50, blank=True, verbose_name='对接人')
# 基本账号信息
handle = models.TextField(blank=True, verbose_name='Handle')
tiktok_url = models.TextField(blank=True, verbose_name='链接')
fans_count = models.CharField(max_length=50, blank=True, verbose_name='粉丝数')
gmv = models.CharField(max_length=100, blank=True, verbose_name='GMV')
# 联系方式
email = models.EmailField(blank=True, verbose_name='邮箱')
phone = models.CharField(max_length=50, blank=True, verbose_name='手机号|WhatsApp')
# 账号属性和报价
account_type = models.CharField(max_length=50, blank=True, verbose_name='账号属性')
price_quote = models.TextField(blank=True, verbose_name='报价')
response_speed = models.CharField(max_length=50, blank=True, verbose_name='回复速度')
# 合作相关
cooperation_intention = models.CharField(max_length=50, blank=True, verbose_name='合作意向')
payment_method = models.CharField(max_length=50, blank=True, verbose_name='支付方式')
payment_account = models.CharField(max_length=100, blank=True, verbose_name='收款账号')
address = models.TextField(blank=True, verbose_name='收件地址')
has_ooin = models.CharField(max_length=10, blank=True, verbose_name='签约OOIN?')
# 渠道和进度
source = models.CharField(max_length=100, blank=True, verbose_name='渠道来源')
contact_status = models.CharField(max_length=50, blank=True, verbose_name='建联进度')
cooperation_brands = models.JSONField(default=list, blank=True, verbose_name='合作品牌')
# 品类信息
system_categories = models.CharField(max_length=100, blank=True, verbose_name='系统展示的带货品类')
actual_categories = models.CharField(max_length=100, blank=True, verbose_name='实际高播放量带货品类')
human_categories = models.CharField(max_length=100, blank=True, verbose_name='达人标想要货品类')
# 其他信息
creator_base = models.CharField(max_length=100, blank=True, verbose_name='达人base')
notes = models.TextField(blank=True, verbose_name='父记录')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
class Meta:
db_table = 'feishu_creators'
verbose_name = '创作者数据'
verbose_name_plural = '创作者数据'
logger = logging.getLogger(__name__)
class FeishuTableRecordsView(APIView):
"""
查询飞书多维表格数据的视图
"""
def post(self, request, *args, **kwargs):
try:
# 获取前端传来的数据
data = request.data
table_url = data.get('table_url')
access_token = data.get('access_token')
filter_exp = data.get('filter')
sort = data.get('sort')
page_size = data.get('page_size', 20)
page_token = data.get('page_token')
if not table_url or not access_token:
return Response(
{"error": "请提供多维表格URL和access_token"},
status=status.HTTP_400_BAD_REQUEST
)
try:
# 从URL中提取app_token和table_id
app_token, table_id = BitableService.extract_params_from_url(table_url)
# 先获取一些样本数据,检查我们能否访问多维表格
sample_data = BitableService.search_records(
app_token=app_token,
table_id=table_id,
access_token=access_token,
filter_exp=filter_exp,
sort=sort,
page_size=page_size,
page_token=page_token
)
return Response(sample_data, status=status.HTTP_200_OK)
except ValueError as ve:
return Response(
{"error": str(ve), "details": "URL格式可能不正确"},
status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
error_details = traceback.format_exc()
return Response(
{
"error": f"查询飞书多维表格失败: {str(e)}",
"details": error_details[:500] # 限制错误详情长度
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class FeishuDataSyncView(APIView):
"""
将飞书多维表格数据同步到数据库的视图
"""
def post(self, request, *args, **kwargs):
try:
# 获取前端传来的数据
data = request.data
table_url = data.get('table_url')
access_token = data.get('access_token')
table_name = data.get('table_name') # 可选,自定义表名
primary_key = data.get('primary_key') # 可选,指定主键
if not table_url or not access_token:
return Response(
{"error": "请提供多维表格URL和access_token"},
status=status.HTTP_400_BAD_REQUEST
)
# 提取参数
try:
app_token, table_id = BitableService.extract_params_from_url(table_url)
# 先获取一些样本数据,检查我们能否访问多维表格
sample_data = BitableService.search_records(
app_token=app_token,
table_id=table_id,
access_token=access_token,
page_size=5
)
# 执行数据同步
result = DataSyncService.sync_data_to_db(
table_url=table_url,
access_token=access_token,
table_name=table_name,
primary_key=primary_key
)
# 添加样本数据到结果中
if result.get('success'):
result['sample_data'] = sample_data.get('items', [])[:3] # 只返回最多3条样本数据
return Response(result, status=status.HTTP_200_OK)
else:
return Response(result, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except ValueError as ve:
return Response(
{"error": str(ve), "details": "URL格式可能不正确"},
status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
import traceback
error_details = traceback.format_exc()
return Response(
{
"error": f"数据同步失败: {str(e)}",
"details": error_details[:500] # 限制错误详情长度
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class FeishuTableMappingListView(APIView):
"""
获取已映射表格列表的视图
"""
def get(self, request, format=None):
"""获取所有映射的表格列表"""
mappings = FeishuTableMapping.objects.all().order_by('-last_sync_time')
result = []
for mapping in mappings:
result.append({
'id': mapping.id,
'app_token': mapping.app_token,
'table_id': mapping.table_id,
'table_url': mapping.table_url,
'table_name': mapping.table_name,
'feishu_table_name': mapping.feishu_table_name,
'last_sync_time': mapping.last_sync_time.strftime('%Y-%m-%d %H:%M:%S') if mapping.last_sync_time else None,
'total_records': mapping.total_records
})
return Response(result)
class FeishuTableMappingDetailView(APIView):
"""
单个表格映射的操作视图
"""
def get_object(self, pk):
try:
return FeishuTableMapping.objects.get(pk=pk)
except FeishuTableMapping.DoesNotExist:
raise Http404
def get(self, request, pk, format=None):
"""获取单个映射详情"""
mapping = self.get_object(pk)
return Response({
'id': mapping.id,
'app_token': mapping.app_token,
'table_id': mapping.table_id,
'table_url': mapping.table_url,
'table_name': mapping.table_name,
'feishu_table_name': mapping.feishu_table_name,
'last_sync_time': mapping.last_sync_time.strftime('%Y-%m-%d %H:%M:%S') if mapping.last_sync_time else None,
'total_records': mapping.total_records
})
def post(self, request, pk, format=None):
"""同步单个表格数据"""
mapping = self.get_object(pk)
# 获取access_token
access_token = request.data.get('access_token')
if not access_token:
return Response(
{"error": "请提供access_token"},
status=status.HTTP_400_BAD_REQUEST
)
# 执行数据同步
result = DataSyncService.sync_data_to_db(
table_url=mapping.table_url,
access_token=access_token,
table_name=mapping.table_name,
auto_sync=True
)
if result.get('success'):
return Response(result, status=status.HTTP_200_OK)
else:
return Response(result, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def delete(self, request, pk, format=None):
"""删除映射关系(不删除数据表)"""
mapping = self.get_object(pk)
mapping.delete()
return Response({"message": "映射关系已删除"}, status=status.HTTP_204_NO_CONTENT)
class GmailExtractionView(APIView):
"""
从飞书多维表格中提取Gmail邮箱并创建对话
"""
@transaction.atomic
def post(self, request, *args, **kwargs):
try:
# 获取请求数据
data = request.data
table_url = data.get('table_url')
access_token = data.get('access_token')
email_field_name = data.get('email_field_name')
user_email = data.get('user_email')
kb_id = data.get('kb_id')
# 验证必填字段
if not table_url or not access_token or not email_field_name or not user_email:
return Response(
{"error": "请提供必要参数: table_url, access_token, email_field_name, user_email"},
status=status.HTTP_400_BAD_REQUEST
)
# 提取Gmail邮箱
gmail_emails, error = GmailExtractionService.find_duplicate_emails(
db_table_name=None, # 不需要检查数据库表
feishu_table_url=table_url,
access_token=access_token,
email_field_name=email_field_name,
user=request.user
)
if error:
return Response({"error": error}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
if not gmail_emails:
return Response({"message": "未找到Gmail邮箱"}, status=status.HTTP_200_OK)
# 创建对话
success_count, error = GmailExtractionService.create_conversations_for_emails(
user=request.user,
user_email=user_email,
emails=gmail_emails,
kb_id=kb_id
)
return Response({
"success": True,
"message": f"成功创建 {success_count} 个Gmail对话",
"total_emails": len(gmail_emails),
"gmail_emails": gmail_emails[:20] if len(gmail_emails) > 20 else gmail_emails,
"error": error
}, status=status.HTTP_200_OK)
except Exception as e:
error_details = traceback.format_exc()
return Response(
{
"error": f"提取Gmail邮箱并创建对话失败: {str(e)}",
"details": error_details[:500]
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class AutoGmailConversationView(APIView):
"""
自动Gmail对话API支持自动发送消息并实时接收和回复达人消息
"""
permission_classes = [IsAuthenticated]
def post(self, request, *args, **kwargs):
"""
创建自动对话发送第一条打招呼消息
请求参数:
- user_email: 用户的Gmail邮箱已授权
- influencer_email: 达人Gmail邮箱
- goal_description: 对话目标描述
"""
try:
# 获取请求数据
data = request.data
user_email = data.get('user_email')
influencer_email = data.get('influencer_email')
goal_description = data.get('goal_description')
# 验证必填参数
if not user_email or not influencer_email or not goal_description:
return Response({
'code': 400,
'message': '缺少必要参数: user_email, influencer_email, goal_description',
'data': None
}, status=status.HTTP_400_BAD_REQUEST)
# 获取或创建活跃对话
# 先查找现有对话
existing_conversation = GmailConversation.objects.filter(
user=request.user,
user_email=user_email,
influencer_email=influencer_email
).first()
if existing_conversation:
# 激活现有对话
existing_conversation.is_active = True
existing_conversation.save()
logger.info(f"找到并激活现有对话: {existing_conversation.conversation_id}")
# 调用服务创建自动对话
success, result, goal = AutoGmailConversationService.create_auto_conversation(
user=request.user,
user_email=user_email,
influencer_email=influencer_email,
greeting_message="", # 使用空字符串,实际消息在服务中已固定
goal_description=goal_description
)
if not success:
return Response({
'code': 400,
'message': result,
'data': None
}, status=status.HTTP_400_BAD_REQUEST)
# 确保对话是活跃的
conversation = GmailConversation.objects.filter(conversation_id=result).first()
if conversation and not conversation.is_active:
conversation.is_active = True
conversation.save()
logger.info(f"已将对话 {conversation.conversation_id} 设置为活跃")
# 设置Gmail推送通知
notification_result, notification_error = GmailService.setup_gmail_push_notification(
user=request.user,
user_email=user_email
)
if not notification_result and notification_error:
logger.warning(f"设置Gmail推送通知失败: {notification_error},但对话创建成功")
# 返回结果
return Response({
'code': 201,
'message': '自动对话创建成功,已发送打招呼消息',
'data': {
'conversation_id': result,
'goal_id': str(goal.id) if goal else None,
'user_email': user_email,
'influencer_email': influencer_email,
'is_active': True,
'goal_description': goal_description,
'push_notification': notification_result
}
}, status=status.HTTP_201_CREATED)
except Exception as e:
logger.error(f"创建自动对话失败: {str(e)}")
error_details = traceback.format_exc()
return Response({
'code': 500,
'message': f'创建自动对话失败: {str(e)}',
'data': {
'details': error_details[:500]
}
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def get(self, request, *args, **kwargs):
"""
获取用户所有自动对话及其状态
"""
try:
# 获取用户所有对话和目标
conversations = AutoGmailConversationService.get_all_user_conversations_with_goals(request.user)
return Response({
'code': 200,
'message': '获取自动对话列表成功',
'data': {
'conversations': conversations
}
})
except Exception as e:
logger.error(f"获取自动对话列表失败: {str(e)}")
error_details = traceback.format_exc()
return Response({
'code': 500,
'message': f'获取自动对话列表失败: {str(e)}',
'data': {
'details': error_details[:500]
}
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View 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'],
},
),
]

View File

@ -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='目标状态'),
),
]

View File

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

View File

@ -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附件的唯一标识符可能很长'),
),
]

View File

@ -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')},
},
),
]

View File

@ -47,15 +47,16 @@ class GmailCredential(models.Model):
class GmailConversation(models.Model):
"""Gmail对话记录跟踪用户和达人之间的邮件交互"""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='gmail_conversations')
user_email = models.EmailField(help_text="用户Gmail邮箱")
influencer_email = models.EmailField(help_text="达人Gmail邮箱")
conversation_id = models.CharField(max_length=100, unique=True, help_text="关联到chat_history的会话ID")
title = models.CharField(max_length=100, default="Gmail对话", help_text="对话标题")
last_sync_time = models.DateTimeField(default=timezone.now, help_text="最后同步时间")
conversation_id = models.CharField(max_length=100, unique=True, help_text="Unique conversation identifier")
user_email = models.EmailField(help_text="User's Gmail address")
influencer_email = models.EmailField(help_text="Influencer's email address")
title = models.CharField(max_length=255, help_text="Conversation title")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
is_active = models.BooleanField(default=True)
metadata = models.JSONField(default=dict, blank=True, null=True, help_text="存储额外信息如已处理的消息ID等")
last_sync_time = models.DateTimeField(null=True, blank=True, help_text="Last time conversation was synced with Gmail")
is_active = models.BooleanField(default=True, help_text="Whether this conversation is active")
has_sent_greeting = models.BooleanField(default=False, help_text="Whether a greeting message has been sent to this conversation")
metadata = models.JSONField(null=True, blank=True, help_text="Additional metadata for the conversation")
def __str__(self):
return f"{self.user.username}: {self.user_email} - {self.influencer_email}"
@ -68,7 +69,7 @@ class GmailAttachment(models.Model):
"""Gmail附件记录"""
conversation = models.ForeignKey(GmailConversation, on_delete=models.CASCADE, related_name='attachments')
email_message_id = models.CharField(max_length=100, help_text="Gmail邮件ID")
attachment_id = models.CharField(max_length=100, help_text="Gmail附件ID")
attachment_id = models.TextField(help_text="Gmail附件的唯一标识符可能很长")
filename = models.CharField(max_length=255, help_text="原始文件名")
file_path = models.CharField(max_length=255, help_text="保存在服务器上的路径")
content_type = models.CharField(max_length=100, help_text="MIME类型")
@ -107,4 +108,78 @@ class ConversationSummary(models.Model):
verbose_name_plural = 'Gmail对话摘要'
def __str__(self):
return f"对话 {self.conversation.id} 摘要"
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'])

View File

@ -1,69 +1,95 @@
from rest_framework import serializers
from .models import GmailCredential
from .models import GmailCredential, GmailConversation, GmailAttachment, UserGoal, AutoReplyConfig
from apps.accounts.models import User
import json
class GmailCredentialSerializer(serializers.ModelSerializer):
client_secret_json = serializers.JSONField(write_only=True, required=False, allow_null=True)
client_secret_file = serializers.FileField(write_only=True, required=False, allow_null=True)
"""Gmail账号凭证的序列化器"""
# 额外字段用于OAuth流程
client_secret_json = serializers.CharField(write_only=True, required=False)
auth_code = serializers.CharField(write_only=True, required=False, allow_blank=True)
email = serializers.EmailField(required=False, allow_blank=True) # Make email optional
class Meta:
model = GmailCredential
fields = ['id', 'email', 'is_default', 'created_at', 'updated_at', 'is_valid',
'client_secret_json', 'client_secret_file', 'auth_code']
read_only_fields = ['created_at', 'updated_at', 'is_valid']
def validate(self, data):
"""Validate client_secret input (either JSON or file)."""
client_secret_json = data.get('client_secret_json')
client_secret_file = data.get('client_secret_file')
auth_code = data.get('auth_code')
# For auth initiation, only client_secret is required
if not auth_code: # Initiation phase
if not client_secret_json and not client_secret_file:
raise serializers.ValidationError(
"Either client_secret_json or client_secret_file is required."
)
if client_secret_json and client_secret_file:
raise serializers.ValidationError(
"Provide only one of client_secret_json or client_secret_file."
)
# For auth completion, both auth_code and client_secret are required
if auth_code and not (client_secret_json or client_secret_file):
raise serializers.ValidationError(
"client_secret_json or client_secret_file is required with auth_code."
)
# Parse client_secret_json if provided
if client_secret_json:
try:
json.dumps(client_secret_json)
except (TypeError, ValueError):
raise serializers.ValidationError("client_secret_json must be valid JSON.")
# Parse client_secret_file if provided
if client_secret_file:
try:
content = client_secret_file.read().decode('utf-8')
client_secret_json = json.loads(content)
data['client_secret_json'] = client_secret_json
except (json.JSONDecodeError, UnicodeDecodeError):
raise serializers.ValidationError("client_secret_file must contain valid JSON.")
fields = ['id', 'user', 'email', 'is_default', 'is_valid', 'created_at', 'updated_at', 'client_secret_json', 'auth_code']
read_only_fields = ['id', 'user', 'email', 'is_valid', 'created_at', 'updated_at']
def validate_client_secret_json(self, value):
"""验证client_secret_json是否为有效的JSON"""
try:
json.loads(value)
return value
except Exception as e:
raise serializers.ValidationError(f"Invalid JSON: {str(e)}")
def to_representation(self, instance):
"""自定义数据表示方式,确保不泄露敏感数据"""
data = super().to_representation(instance)
# 移除敏感字段
if 'client_secret_json' in data:
del data['client_secret_json']
if 'auth_code' in data:
del data['auth_code']
return data
def validate_email(self, value):
"""Ensure email is unique for the user (only for completion)."""
if not value: # Email is optional during initiation
return value
class SimpleGmailConversationSerializer(serializers.ModelSerializer):
"""Gmail对话的简易序列化器"""
class Meta:
model = GmailConversation
fields = ['conversation_id', 'user_email', 'influencer_email', 'title', 'created_at', 'updated_at', 'is_active']
class GmailAttachmentSerializer(serializers.ModelSerializer):
"""Gmail附件的序列化器"""
url = serializers.SerializerMethodField()
class Meta:
model = GmailAttachment
fields = ['id', 'filename', 'content_type', 'size', 'sender_email', 'created_at', 'url']
def get_url(self, obj):
"""获取附件URL"""
return obj.get_absolute_url()
class UserGoalSerializer(serializers.ModelSerializer):
"""用户目标的序列化器"""
conversation_id = serializers.CharField(write_only=True, required=False)
class Meta:
model = UserGoal
fields = ['id', 'user', 'conversation', 'description', 'status', 'is_active',
'created_at', 'updated_at', 'completion_time', 'last_activity_time',
'conversation_id', 'metadata']
read_only_fields = ['id', 'user', 'created_at', 'updated_at', 'completion_time', 'last_activity_time']
def to_representation(self, instance):
"""自定义数据表示方式"""
data = super().to_representation(instance)
# 添加conversation_id方便前端使用
if instance.conversation:
data['conversation_id'] = instance.conversation.conversation_id
return data
class AutoReplyConfigSerializer(serializers.ModelSerializer):
"""自动回复配置的序列化器"""
class Meta:
model = AutoReplyConfig
fields = ['id', 'user', 'user_email', 'influencer_email', 'is_enabled',
'goal_description', 'reply_template', 'max_replies', 'current_replies',
'last_reply_time', 'created_at', 'updated_at', 'metadata']
read_only_fields = ['id', 'user', 'current_replies', 'last_reply_time', 'created_at', 'updated_at']
def validate(self, data):
"""验证用户邮箱是否已授权"""
user = self.context['request'].user
if self.instance: # Update case
if GmailCredential.objects.filter(user=user, email=value).exclude(id=self.instance.id).exists():
raise serializers.ValidationError("This Gmail account is already added.")
else: # Create case
if GmailCredential.objects.filter(user=user, email=value).exists():
raise serializers.ValidationError("This Gmail account is already added.")
return value
user_email = data.get('user_email')
if user_email:
# 检查用户是否已授权该邮箱
credential = GmailCredential.objects.filter(user=user, email=user_email, is_valid=True).first()
if not credential:
raise serializers.ValidationError({"user_email": f"邮箱 {user_email} 未授权或授权已失效"})
return data

File diff suppressed because it is too large Load Diff

View 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

View File

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

View File

@ -1,10 +1,15 @@
import traceback
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework import status
from .serializers import GmailCredentialSerializer
from rest_framework import status, viewsets
from rest_framework.decorators import action
from apps.accounts.services.utils import validate_uuid_param
from apps.gmail.services.goal_service import generate_recommended_reply, get_conversation_summary, get_last_message
from .serializers import GmailCredentialSerializer, UserGoalSerializer, AutoReplyConfigSerializer
from .services.gmail_service import GmailService
from .models import GmailCredential, GmailConversation, GmailAttachment
from .models import GmailCredential, GmailConversation, GmailAttachment, UserGoal, ConversationSummary
from django.shortcuts import get_object_or_404
import logging
import os
@ -14,6 +19,13 @@ from django.core.files.base import ContentFile
import json
import base64
import threading
from apps.feishu.services.auto_gmail_conversation_service import AutoGmailConversationService
from django.utils import timezone
from django.http import HttpResponse, Http404
from django.db.models import Q, Prefetch
import uuid
from apps.common.services.ai_service import AIService
from apps.chat.models import ChatHistory
# 配置日志记录器,用于记录视图操作的调试、警告和错误信息
logger = logging.getLogger(__name__)
@ -125,125 +137,233 @@ class GmailAuthCompleteView(APIView):
}, status=status.HTTP_400_BAD_REQUEST)
class GmailCredentialListView(APIView):
class GmailCredentialViewSet(viewsets.ModelViewSet):
"""
API 视图用于列出用户的所有 Gmail 凭证
Gmail凭证管理视图集提供对Gmail账户凭证的完整CRUD操作
此视图集替代了旧的GmailCredentialListView和GmailCredentialDetailView
提供了更完整的功能和更统一的API接口
支持以下操作
- list: 获取凭证列表
- create: 创建新凭证(初始化OAuth或完成OAuth)
- retrieve: 获取单个凭证详情
- update: 完全更新凭证
- partial_update: 部分更新凭证
- destroy: 删除凭证
- set_default: 设置默认凭证(自定义操作)
"""
permission_classes = [IsAuthenticated] # 限制访问,仅允许已认证用户
def get(self, request):
permission_classes = [IsAuthenticated]
serializer_class = GmailCredentialSerializer
def get_queryset(self):
"""获取当前用户的Gmail凭证"""
return GmailCredential.objects.filter(user=self.request.user)
def perform_create(self, serializer):
"""创建凭证时自动关联当前用户"""
serializer.save(user=self.request.user)
def list(self, request, *args, **kwargs):
"""
处理 GET 请求返回用户的所有 Gmail 凭证列表
Args:
request: Django REST Framework 请求对象
Returns:
Response: 包含凭证列表的 JSON 响应
Status Codes:
200: 成功返回凭证列表
列出用户的Gmail账号凭证
返回当前用户所有Gmail账号凭证的列表
"""
# 获取用户关联的所有 Gmail 凭证
credentials = request.user.gmail_credentials.all()
# 序列化凭证数据
serializer = GmailCredentialSerializer(credentials, many=True, context={'request': request})
queryset = self.get_queryset()
serializer = self.get_serializer(queryset, many=True)
return Response({
'code': 200,
'message': '成功获取凭证列表',
'message': '获取Gmail账号列表成功',
'data': serializer.data
}, status=status.HTTP_200_OK)
class GmailCredentialDetailView(APIView):
"""
API 视图用于管理特定 Gmail 凭证的获取更新和删除
"""
permission_classes = [IsAuthenticated] # 限制访问,仅允许已认证用户
def get(self, request, pk):
})
def retrieve(self, request, *args, **kwargs):
"""
处理 GET 请求获取特定 Gmail 凭证的详细信息
Args:
request: Django REST Framework 请求对象
pk: 凭证的主键 ID
Returns:
Response: 包含凭证详细信息的 JSON 响应
Status Codes:
200: 成功返回凭证信息
404: 未找到指定凭证
获取特定Gmail凭证的详细信息
根据ID返回单个Gmail账号凭证的详细信息
"""
# 获取用户拥有的指定凭证,未找到则返回 404
credential = get_object_or_404(GmailCredential, pk=pk, user=request.user)
serializer = GmailCredentialSerializer(credential, context={'request': request})
instance = self.get_object()
serializer = self.get_serializer(instance)
return Response({
'code': 200,
'message': '成功获取凭证详情',
'data': serializer.data
}, status=status.HTTP_200_OK)
def patch(self, request, pk):
})
def create(self, request, *args, **kwargs):
"""
处理 PATCH 请求更新特定 Gmail 凭证如设置为默认凭证
Args:
request: Django REST Framework 请求对象包含更新数据
pk: 凭证的主键 ID
Returns:
Response: 包含更新后凭证数据的 JSON 响应或错误信息
Status Codes:
200: 成功更新凭证
400: 请求数据无效
404: 未找到指定凭证
创建Gmail账号凭证 - 初始化或完成OAuth授权
此接口有两个功能
1. 如果未提供auth_code则初始化OAuth并返回授权URL
2. 如果提供了auth_code则完成OAuth并保存凭证
请求参数:
- client_secret_json: Google Cloud项目的客户端密钥
- auth_code: [可选] 授权码用于完成OAuth流程
"""
# 获取用户拥有的指定凭证
credential = get_object_or_404(GmailCredential, pk=pk, user=request.user)
serializer = GmailCredentialSerializer(credential, data=request.data, partial=True, context={'request': request})
if serializer.is_valid():
try:
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
auth_code = serializer.validated_data.get('auth_code')
client_secret_json = serializer.validated_data.get('client_secret_json')
if not auth_code: # 初始化OAuth
auth_url = GmailService.start_oauth(client_secret_json)
return Response({
'code': 200,
'message': '授权URL生成成功',
'data': {'auth_url': auth_url}
})
else: # 完成OAuth
email, credentials = GmailService.complete_oauth(client_secret_json, auth_code)
if not email:
return Response({
'code': 400,
'message': f'授权失败: {credentials}',
'data': None
}, status=status.HTTP_400_BAD_REQUEST)
# 保存凭证
serializer.save(
user=request.user,
email=email,
credentials=credentials,
is_valid=True
)
return Response({
'code': 201,
'message': '授权成功',
'data': {
'id': serializer.instance.id,
'email': email,
'is_valid': True
}
}, status=status.HTTP_201_CREATED)
except Exception as e:
logger.error(f"创建Gmail凭证失败: {str(e)}")
logger.error(traceback.format_exc())
return Response({
'code': 500,
'message': f'创建Gmail凭证失败: {str(e)}',
'data': None
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def update(self, request, *args, **kwargs):
"""
完全更新Gmail凭证信息
更新Gmail凭证的所有可编辑字段
如果设置is_default=True会自动将其他凭证设为非默认
"""
try:
instance = self.get_object()
serializer = self.get_serializer(instance, data=request.data)
serializer.is_valid(raise_exception=True)
# 如果设置为默认凭证,清除其他凭证的默认状态
if serializer.validated_data.get('is_default', False):
GmailCredential.objects.filter(user=request.user).exclude(id=credential.id).update(is_default=False)
GmailCredential.objects.filter(user=request.user).exclude(id=instance.id).update(is_default=False)
serializer.save()
return Response({
'code': 200,
'message': '成功更新凭证',
'data': serializer.data
}, status=status.HTTP_200_OK)
# 返回无效数据错误
return Response({
'code': 400,
'message': '请求数据无效',
'data': serializer.errors
}, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, pk):
})
except Exception as e:
logger.error(f"更新Gmail凭证失败: {str(e)}")
return Response({
'code': 500,
'message': f'更新Gmail凭证失败: {str(e)}',
'data': None
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def partial_update(self, request, *args, **kwargs):
"""
处理 DELETE 请求删除特定 Gmail 凭证
Args:
request: Django REST Framework 请求对象
pk: 凭证的主键 ID
Returns:
Response: 空响应表示删除成功
Status Codes:
204: 成功删除凭证
404: 未找到指定凭证
部分更新Gmail凭证信息
更新Gmail凭证的部分字段
如果设置is_default=True会自动将其他凭证设为非默认
"""
# 获取并删除用户拥有的指定凭证
credential = get_object_or_404(GmailCredential, pk=pk, user=request.user)
credential.delete()
return Response({
'code': 204,
'message': '凭证已成功删除',
'data': None
}, status=status.HTTP_204_NO_CONTENT)
try:
instance = self.get_object()
serializer = self.get_serializer(instance, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
# 如果设置为默认凭证,清除其他凭证的默认状态
if serializer.validated_data.get('is_default', False):
GmailCredential.objects.filter(user=request.user).exclude(id=instance.id).update(is_default=False)
serializer.save()
return Response({
'code': 200,
'message': '成功更新凭证',
'data': serializer.data
})
except Exception as e:
logger.error(f"更新Gmail凭证失败: {str(e)}")
return Response({
'code': 500,
'message': f'更新Gmail凭证失败: {str(e)}',
'data': None
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def destroy(self, request, *args, **kwargs):
"""
删除Gmail账号凭证
根据ID删除指定的Gmail凭证
"""
try:
instance = self.get_object()
self.perform_destroy(instance)
return Response({
'code': 200,
'message': '删除Gmail账号成功',
'data': None
})
except Exception as e:
logger.error(f"删除Gmail凭证失败: {str(e)}")
return Response({
'code': 500,
'message': f'删除Gmail凭证失败: {str(e)}',
'data': None
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=True, methods=['post'])
def set_default(self, request, pk=None):
"""设置默认Gmail账号"""
try:
credential = self.get_object()
# 取消其他默认账号
GmailCredential.objects.filter(
user=request.user,
is_default=True
).update(is_default=False)
# 设置当前账号为默认
credential.is_default = True
credential.save()
return Response({
'code': 200,
'message': f'已将 {credential.email} 设为默认Gmail账号',
'data': None
})
except Exception as e:
logger.error(f"设置默认Gmail账号失败: {str(e)}")
return Response({
'code': 500,
'message': f'设置默认Gmail账号失败: {str(e)}',
'data': None
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class GmailConversationView(APIView):
@ -657,25 +777,40 @@ class GmailWebhookView(APIView):
这个端点不需要认证因为它由Google的Pub/Sub服务调用
"""
permission_classes = [] # 不需要认证
def post(self, request):
"""
处理POST请求接收Gmail Pub/Sub推送通知
处理POST请求接收Gmail Pub/Sub推送通知
从中获取邮箱地址和历史ID然后保存最新邮件到数据库
Args:
request: Django REST Framework请求对象包含Pub/Sub消息
Returns:
Response: 接收结果的JSON响应
Response: 处理结果的JSON响应
"""
try:
logger.info(f"收到Gmail推送通知: {request.data}")
print("\n" + "="*100)
print("[Gmail Webhook] 收到推送通知")
print("="*100)
# 打印请求时间和基本信息
current_time = timezone.now()
print(f"接收时间: {current_time.strftime('%Y-%m-%d %H:%M:%S.%f')}")
print(f"请求头: {dict(request.headers)}")
print(f"原始数据: {request.data}")
# 解析推送消息
message = request.data.get('message', {})
data = message.get('data', '')
message_id = message.get('messageId', '')
subscription = request.data.get('subscription', '')
print(f"消息ID: {message_id}")
print(f"订阅名称: {subscription}")
if not data:
print("[Gmail Webhook] 错误: 无效的推送消息格式缺少data字段")
return Response({
'code': 400,
'message': '无效的推送消息格式',
@ -685,13 +820,17 @@ class GmailWebhookView(APIView):
# Base64解码消息数据
try:
decoded_data = json.loads(base64.b64decode(data).decode('utf-8'))
logger.info(f"解码后的推送数据: {decoded_data}")
print(f"[Gmail Webhook] 解码后的数据: {decoded_data}")
# 处理Gmail通知
# 获取Gmail通知关键信息
email_address = decoded_data.get('emailAddress')
history_id = decoded_data.get('historyId')
print(f"邮箱地址: {email_address}")
print(f"历史ID: {history_id}")
if not email_address:
print("[Gmail Webhook] 错误: 推送数据缺少邮箱地址")
return Response({
'code': 400,
'message': '推送数据缺少邮箱地址',
@ -701,28 +840,33 @@ class GmailWebhookView(APIView):
# 查找对应的Gmail凭证
credential = GmailCredential.objects.filter(email=email_address, is_valid=True).first()
if credential:
# 即使没有history_id也尝试处理因为我们现在有了备用机制
if not history_id:
logger.warning(f"推送通知中没有historyId将使用凭证中保存的历史ID")
user = credential.user
print(f"[Gmail Webhook] 找到有效凭证: 用户ID {user.id}, 邮箱 {email_address}")
# 启动后台任务处理新邮件
thread = threading.Thread(
target=GmailService.process_new_emails,
args=(credential.user, credential, history_id)
)
thread.daemon = True
thread.start()
# 获取并保存最新邮件
print("[Gmail Webhook] 开始获取最新邮件...")
# 处理新邮件,使用静态方法而不是实例化类
try:
# 使用GmailService的静态方法处理新邮件
processed_emails = GmailService.process_new_emails(user, credential, history_id)
print(f"[Gmail Webhook] 新邮件处理完成,共 {len(processed_emails)}")
# 更新凭证的历史ID移到处理完成后再更新
if history_id:
credential.last_history_id = history_id
credential.save()
print(f"[Gmail Webhook] 已更新凭证历史ID: {history_id}")
except Exception as e:
print(f"[Gmail Webhook] 获取最新邮件失败: {str(e)}")
logger.error(f"获取最新邮件失败: {str(e)}")
else:
print(f"[Gmail Webhook] 警告: 未找到对应的Gmail凭证: {email_address}")
logger.warning(f"收到推送通知但未找到对应的Gmail凭证: {email_address}")
# 确认接收
return Response({
'code': 200,
'message': '成功接收推送通知',
'data': None
})
except Exception as e:
print(f"[Gmail Webhook] 解析推送数据失败: {str(e)}")
logger.error(f"解析推送数据失败: {str(e)}")
return Response({
'code': 400,
@ -730,11 +874,22 @@ class GmailWebhookView(APIView):
'data': None
}, status=status.HTTP_400_BAD_REQUEST)
print("[Gmail Webhook] 推送处理完成")
print("="*100 + "\n")
# 返回成功响应
return Response({
'code': 200,
'message': '推送通知处理成功',
'data': None
})
except Exception as e:
print(f"[Gmail Webhook] 处理推送通知失败: {str(e)}")
logger.error(f"处理Gmail推送通知失败: {str(e)}")
return Response({
'code': 500,
'message': f'处理Gmail推送通知失败: {str(e)}',
'message': f'处理推送通知失败: {str(e)}',
'data': None
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@ -818,3 +973,317 @@ class GmailConversationSummaryView(APIView):
'data': None
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class GmailGoalView(APIView):
"""
用户目标API - 支持用户为每个对话设置不同的目标
"""
permission_classes = [IsAuthenticated]
def get(self, request, conversation_id=None):
"""
获取用户的目标
如果提供了conversation_id则获取该对话的活跃目标
如果没有提供conversation_id则获取用户的所有活跃目标
"""
try:
if conversation_id:
# 获取特定对话的活跃目标
goal = AutoGmailConversationService.get_active_goals_for_conversation(
request.user,
conversation_id
)
if not goal:
return Response({
'code': 404,
'message': '未找到该对话的活跃目标',
'data': None
}, status=status.HTTP_404_NOT_FOUND)
serializer = UserGoalSerializer(goal)
return Response({
'code': 200,
'message': '获取目标成功',
'data': serializer.data
})
else:
# 获取用户的所有活跃目标
goals = UserGoal.objects.filter(user=request.user, is_active=True)
# 准备响应数据,包括对话信息
result = []
for goal in goals:
goal_data = UserGoalSerializer(goal).data
result.append(goal_data)
return Response({
'code': 200,
'message': '获取目标列表成功',
'data': result
})
except Exception as e:
logger.error(f"获取用户目标失败: {str(e)}")
logger.error(traceback.format_exc())
return Response({
'code': 500,
'message': f'获取用户目标失败: {str(e)}',
'data': None
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def post(self, request):
"""创建对话目标"""
try:
# 获取请求参数
conversation_id = request.data.get('conversation_id')
goal_description = request.data.get('goal_description')
# 验证必要参数
if not conversation_id or not goal_description:
return Response({
'code': 400,
'message': '缺少必要参数: conversation_id 或 goal_description',
'data': None
}, status=status.HTTP_400_BAD_REQUEST)
# 查找对话
conversation = GmailConversation.objects.filter(conversation_id=conversation_id).first()
if not conversation:
return Response({
'code': 404,
'message': '对话不存在',
'data': None
}, status=status.HTTP_404_NOT_FOUND)
# 检查权限
if conversation.user.id != request.user.id:
return Response({
'code': 403,
'message': '无权限访问此对话',
'data': None
}, status=status.HTTP_403_FORBIDDEN)
# 查找现有目标
existing_goal = UserGoal.objects.filter(
user=request.user,
conversation=conversation,
is_active=True
).first()
if existing_goal:
# 如果存在活跃目标,直接更新它
existing_goal.description = goal_description
existing_goal.status = 'pending' # 重置状态
existing_goal.last_activity_time = timezone.now()
existing_goal.metadata = existing_goal.metadata or {}
existing_goal.metadata.update({
'updated_at': timezone.now().isoformat(),
'influencer_email': conversation.influencer_email,
'user_email': conversation.user_email
})
existing_goal.save()
logger.info(f"用户 {request.user.username} 更新了对话 {conversation_id} 的目标")
serializer = UserGoalSerializer(existing_goal)
return Response({
'code': 200,
'message': '目标更新成功',
'data': serializer.data
})
else:
# 创建新目标
goal = UserGoal.objects.create(
user=request.user,
conversation=conversation,
description=goal_description,
is_active=True,
status='pending',
metadata={
'created_at': timezone.now().isoformat(),
'influencer_email': conversation.influencer_email,
'user_email': conversation.user_email
}
)
logger.info(f"用户 {request.user.username} 为对话 {conversation_id} 创建了新目标")
serializer = UserGoalSerializer(goal)
return Response({
'code': 201,
'message': '目标创建成功',
'data': serializer.data
}, status=status.HTTP_201_CREATED)
except Exception as e:
logger.error(f"创建用户目标失败: {str(e)}")
logger.error(traceback.format_exc())
return Response({
'code': 500,
'message': f'创建用户目标失败: {str(e)}',
'data': None
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def delete(self, request, conversation_id):
"""停用对话目标"""
try:
# 获取该对话的活跃目标
goal = AutoGmailConversationService.get_active_goals_for_conversation(
request.user,
conversation_id
)
if not goal:
return Response({
'code': 404,
'message': '未找到该对话的活跃目标',
'data': None
}, status=status.HTTP_404_NOT_FOUND)
# 停用目标
goal.is_active = False
goal.save()
return Response({
'code': 200,
'message': '目标已停用',
'data': None
})
except Exception as e:
logger.error(f"停用目标失败: {str(e)}")
logger.error(traceback.format_exc())
return Response({
'code': 500,
'message': f'停用目标失败: {str(e)}',
'data': None
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class SimpleRecommendedReplyView(APIView):
"""
通过conversation_id一键获取目标对话摘要和推荐回复
"""
permission_classes = [IsAuthenticated]
def post(self, request):
"""
直接通过conversation_id获取推荐回复
请求参数:
- conversation_id: 对话ID (必填)
响应:
- 目标信息
- 对话摘要
- 达人最后发送的消息
- 推荐回复内容
"""
try:
# 获取请求参数
conversation_id = request.data.get('conversation_id')
# 验证必填参数
if not conversation_id:
return Response({
'code': 400,
'message': '缺少必要参数: conversation_id',
'data': None
}, status=status.HTTP_400_BAD_REQUEST)
# 验证对话存在并且属于当前用户
# 直接通过conversation_id查找不进行UUID验证
# Gmail对话ID可能是形如 gmail_ae51a9e8-ff35-4507-a63b-7d8091327e1e_44a2aaac 的格式
try:
conversation = GmailConversation.objects.filter(conversation_id=conversation_id).first()
if not conversation:
return Response({
'code': 404,
'message': '对话不存在',
'data': None
}, status=status.HTTP_404_NOT_FOUND)
if conversation.user != request.user:
return Response({
'code': 403,
'message': '无权限访问该对话',
'data': None
}, status=status.HTTP_403_FORBIDDEN)
except Exception as e:
logger.error(f"查找对话失败: {str(e)}")
return Response({
'code': 500,
'message': f'查找对话失败: {str(e)}',
'data': None
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
# 1. 获取对话的活跃目标
goal = UserGoal.objects.filter(
user=request.user,
conversation=conversation,
is_active=True
).first()
if not goal:
return Response({
'code': 404,
'message': '未找到该对话的活跃目标',
'data': None
}, status=status.HTTP_404_NOT_FOUND)
# 2. 获取对话摘要
conversation_summary = get_conversation_summary(conversation_id)
if not conversation_summary:
conversation_summary = "无对话摘要"
# 3. 获取最后一条达人消息
last_message = get_last_message(conversation_id)
if not last_message:
return Response({
'code': 400,
'message': '对话中没有达人消息,无法生成回复',
'data': None
}, status=status.HTTP_400_BAD_REQUEST)
# 4. 生成推荐回复
reply_content, error = generate_recommended_reply(
request.user,
goal.description,
conversation_summary,
last_message
)
if error:
return Response({
'code': 500,
'message': f'生成推荐回复失败: {error}',
'data': None
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
# 返回完整数据
return Response({
'code': 200,
'message': '获取成功',
'data': {
'goal': {
'id': str(goal.id),
'description': goal.description,
'status': goal.status
},
'conversation_summary': conversation_summary,
'last_message': last_message,
'recommended_reply': reply_content
}
})
except Exception as e:
logger.error(f"一键获取推荐回复失败: {str(e)}")
logger.error(traceback.format_exc())
return Response({
'code': 500,
'message': f'服务器错误: {str(e)}',
'data': None
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

527
apps/operation/README.md Normal file
View 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>

View 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'

View File

@ -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='发布账号')),

View File

@ -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'),
),
]

View File

@ -1,18 +1,15 @@
from django.db import models
import uuid
from django.utils import timezone
from apps.knowledge_base.models import KnowledgeBase, KnowledgeBaseDocument
from apps.accounts.models import User
# Create your models here.
# 我们可以在这里添加额外的模型或关系但现在使用user_management中的现有模型
# 从user_management迁移过来的模型
class OperatorAccount(models.Model):
"""运营账号信息表"""
id = models.AutoField(primary_key=True) # 保留自动递增的ID字段
id = models.AutoField(primary_key=True)
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID')
POSITION_CHOICES = [
@ -36,54 +33,57 @@ class OperatorAccount(models.Model):
class Meta:
verbose_name = '运营账号'
verbose_name_plural = '运营账号'
def __str__(self):
return f"{self.real_name} ({self.username})"
class PlatformAccount(models.Model):
"""平台账号信息表"""
"""平台账号模型"""
PLATFORM_CHOICES = [
('youtube', 'YouTube'),
('tiktok', 'TikTok'),
('bilibili', 'Bilibili'),
('facebook', 'Facebook'),
('instagram', 'Instagram'),
('twitter', 'Twitter'),
('other', '其他平台')
]
STATUS_CHOICES = [
('active', '正常'),
('restricted', '限流'),
('suspended', '封禁'),
('inactive', '未激活'),
('inactive', '未激活')
]
PLATFORM_CHOICES = [
('youtube', 'YouTube'),
('tiktok', 'TikTok'),
('twitter', 'Twitter/X'),
('instagram', 'Instagram'),
('facebook', 'Facebook'),
('bilibili', 'Bilibili'),
]
operator = models.ForeignKey(OperatorAccount, on_delete=models.CASCADE, related_name='platform_accounts', verbose_name='关联运营')
operator = models.ForeignKey(OperatorAccount, on_delete=models.CASCADE, related_name='platform_accounts', verbose_name='运营账号')
name = models.CharField(max_length=100, verbose_name='账户名称', default='', blank=True)
platform_name = models.CharField(max_length=20, choices=PLATFORM_CHOICES, verbose_name='平台名称')
account_name = models.CharField(max_length=100, verbose_name='账号名称')
account_id = models.CharField(max_length=100, verbose_name='账号ID')
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active', verbose_name='账号状态')
followers_count = models.IntegerField(default=0, verbose_name='粉丝数')
account_url = models.URLField(verbose_name='账号链接')
account_url = models.URLField(blank=True, null=True, verbose_name='账号链接')
description = models.TextField(blank=True, null=True, verbose_name='账号描述')
# 新增字段
tags = models.CharField(max_length=255, blank=True, null=True, verbose_name='标签', help_text='用逗号分隔的标签列表')
tags = models.CharField(max_length=255, blank=True, null=True, verbose_name='标签', help_text='逗号分隔的标签列表')
profile_image = models.URLField(blank=True, null=True, verbose_name='头像URL')
last_posting = models.DateTimeField(blank=True, null=True, verbose_name='最后发布时间')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
last_login = models.DateTimeField(blank=True, null=True, verbose_name='最后登录时间')
def __str__(self):
return f"{self.platform_name} - {self.account_name}"
class Meta:
verbose_name = '平台账号'
verbose_name_plural = '平台账号'
unique_together = ('platform_name', 'account_id')
def __str__(self):
return f"{self.account_name} ({self.platform_name})"
ordering = ['-created_at']
indexes = [
models.Index(fields=['platform_name']),
models.Index(fields=['account_id']),
models.Index(fields=['status']),
]
class Video(models.Model):
"""视频信息表"""
@ -109,18 +109,13 @@ class Video(models.Model):
shares_count = models.IntegerField(default=0, verbose_name='分享数')
tags = models.CharField(max_length=500, blank=True, null=True, verbose_name='标签')
publish_time = models.DateTimeField(blank=True, null=True, verbose_name='发布时间')
scheduled_time = models.DateTimeField(blank=True, null=True, verbose_name='计划发布时间')
video_id = models.CharField(max_length=100, blank=True, null=True, verbose_name='视频ID')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
class Meta:
verbose_name = '视频'
verbose_name_plural = '视频'
def __str__(self):
return self.title
def save(self, *args, **kwargs):
if self.status == 'published' and not self.publish_time:
self.publish_time = timezone.now()
super().save(*args, **kwargs)
return f"{self.title} ({self.platform_account.account_name})"

View File

@ -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": "获取数据成功",

View File

@ -1,46 +1,39 @@
from rest_framework import serializers
from .models import OperatorAccount, PlatformAccount, Video
from apps.knowledge_base.models import KnowledgeBase, KnowledgeBaseDocument
import uuid
from django.db.models import Q
class OperatorAccountSerializer(serializers.ModelSerializer):
id = serializers.UUIDField(read_only=False, required=False) # 允许前端不提供ID但如果提供则必须是有效的UUID
id = serializers.IntegerField(read_only=True) # ID是自动递增的整数字段
class Meta:
model = OperatorAccount
fields = ['id', 'username', 'password', 'real_name', 'email', 'phone', 'position', 'department', 'is_active', 'created_at', 'updated_at']
read_only_fields = ['created_at', 'updated_at']
fields = ['id', 'uuid', 'username', 'password', 'real_name', 'email', 'phone', 'position', 'department', 'is_active', 'created_at', 'updated_at']
read_only_fields = ['created_at', 'updated_at', 'uuid']
extra_kwargs = {
'password': {'write_only': True}
}
def create(self, validated_data):
# 如果没有提供ID则生成一个UUID
if 'id' not in validated_data:
validated_data['id'] = uuid.uuid4()
password = validated_data.pop('password', None)
instance = self.Meta.model(**validated_data)
if password:
instance.password = password # 在实际应用中应该加密存储密码
instance.save()
return instance
class PlatformAccountSerializer(serializers.ModelSerializer):
operator_name = serializers.CharField(source='operator.real_name', read_only=True)
platforms = serializers.SerializerMethodField()
class Meta:
model = PlatformAccount
fields = ['id', 'operator', 'operator_name', 'platform_name', 'account_name', 'account_id',
fields = ['id', 'operator', 'operator_name', 'name', 'platform_name', 'account_name', 'account_id',
'status', 'followers_count', 'account_url', 'description',
'tags', 'profile_image', 'last_posting',
'created_at', 'updated_at', 'last_login']
'created_at', 'updated_at', 'last_login', 'platforms']
read_only_fields = ['id', 'created_at', 'updated_at']
def get_platforms(self, obj):
# 不会在查询时调用,但需要实现该方法,默认返回空数组
return []
def to_internal_value(self, data):
# 处理operator字段可能是字符串格式的UUID
# 处理operator字段可能是字符串格式的ID
if 'operator' in data and isinstance(data['operator'], str):
try:
# 尝试获取对应的运营账号对象
@ -53,7 +46,94 @@ class PlatformAccountSerializer(serializers.ModelSerializer):
# 其他类型的错误如ID格式不正确等
pass
# 处理platforms字段从数组中提取出第一个元素的信息
if 'platforms' in data and isinstance(data['platforms'], list) and len(data['platforms']) > 0:
data = data.copy()
platform_data = data['platforms'][0]
# 将platforms中的字段移到顶层
if 'platform_name' in platform_data:
data['platform_name'] = platform_data['platform_name']
if 'account_url' in platform_data:
data['account_url'] = platform_data['account_url']
if 'account_id' in platform_data:
data['account_id'] = platform_data['account_id']
if 'account_name' in platform_data:
data['account_name'] = platform_data['account_name']
# 删除platforms字段避免验证错误
del data['platforms']
# 处理tags字段将列表转换为逗号分隔的字符串
if 'tags' in data and isinstance(data['tags'], list):
data = data.copy() if not isinstance(data, dict) else data
data['tags'] = ','.join(data['tags'])
return super().to_internal_value(data)
def to_representation(self, instance):
"""将tags字符串转换为数组"""
representation = super().to_representation(instance)
if representation.get('tags'):
representation['tags'] = representation['tags'].split(',')
return representation
class PlatformDetailSerializer(serializers.Serializer):
"""平台详情序列化器,用于多平台账号创建"""
platform_name = serializers.ChoiceField(choices=PlatformAccount.PLATFORM_CHOICES)
platform_url = serializers.URLField()
class MultiPlatformAccountSerializer(serializers.Serializer):
"""多平台账号创建序列化器"""
operator = serializers.PrimaryKeyRelatedField(queryset=OperatorAccount.objects.all())
name = serializers.CharField(max_length=100, required=False, allow_blank=True)
account_name = serializers.CharField(max_length=100)
account_id = serializers.CharField(max_length=100)
status = serializers.ChoiceField(choices=PlatformAccount.STATUS_CHOICES, default='active')
followers_count = serializers.IntegerField(default=0)
description = serializers.CharField(required=False, allow_blank=True, allow_null=True)
# 使用CharField而不是ListField我们会在to_internal_value和to_representation中手动处理转换
tags = serializers.CharField(required=False, allow_blank=True, allow_null=True, max_length=255)
profile_image = serializers.URLField(required=False, allow_blank=True, allow_null=True)
last_posting = serializers.DateTimeField(required=False, allow_null=True)
platforms = PlatformDetailSerializer(many=True)
def to_internal_value(self, data):
# 处理tags字段将列表转换为逗号分隔的字符串
if 'tags' in data and isinstance(data['tags'], list):
data = data.copy()
data['tags'] = ','.join(data['tags'])
# 处理operator字段可能是字符串类型的ID
if 'operator' in data and isinstance(data['operator'], str):
try:
# 尝试通过ID查找运营账号
operator_id = data['operator']
try:
# 先尝试通过整数ID查找
operator_id_int = int(operator_id)
operator = OperatorAccount.objects.get(id=operator_id_int)
data['operator'] = operator.id
except (ValueError, OperatorAccount.DoesNotExist):
# 如果无法转换为整数或找不到对应账号,尝试通过用户名或真实姓名查找
operator = OperatorAccount.objects.filter(
Q(username=operator_id) | Q(real_name=operator_id)
).first()
if operator:
data['operator'] = operator.id
except Exception as e:
pass
return super().to_internal_value(data)
def to_representation(self, instance):
"""将tags字符串转换为数组"""
representation = super().to_representation(instance)
if representation.get('tags') and isinstance(representation['tags'], str):
representation['tags'] = representation['tags'].split(',')
return representation
class VideoSerializer(serializers.ModelSerializer):
@ -65,12 +145,17 @@ class VideoSerializer(serializers.ModelSerializer):
fields = ['id', 'platform_account', 'platform_account_name', 'platform_name', 'title',
'description', 'video_url', 'local_path', 'thumbnail_url', 'status',
'views_count', 'likes_count', 'comments_count', 'shares_count', 'tags',
'publish_time', 'scheduled_time', 'created_at', 'updated_at']
'publish_time', 'video_id', 'created_at', 'updated_at']
read_only_fields = ['id', 'created_at', 'updated_at', 'views_count', 'likes_count',
'comments_count', 'shares_count']
def to_internal_value(self, data):
# 处理platform_account字段可能是字符串格式的UUID
# 处理tags字段将列表转换为逗号分隔的字符串
if 'tags' in data and isinstance(data['tags'], list):
data = data.copy()
data['tags'] = ','.join(data['tags'])
# 处理platform_account字段可能是字符串格式的ID
if 'platform_account' in data and isinstance(data['platform_account'], str):
try:
# 尝试获取对应的平台账号对象
@ -84,19 +169,11 @@ class VideoSerializer(serializers.ModelSerializer):
pass
return super().to_internal_value(data)
class KnowledgeBaseSerializer(serializers.ModelSerializer):
class Meta:
model = KnowledgeBase
fields = ['id', 'user_id', 'name', 'desc', 'type', 'department', 'group',
'external_id', 'create_time', 'update_time']
read_only_fields = ['id', 'create_time', 'update_time']
class KnowledgeBaseDocumentSerializer(serializers.ModelSerializer):
class Meta:
model = KnowledgeBaseDocument
fields = ['id', 'knowledge_base', 'document_id', 'document_name',
'external_id', 'uploader_name', 'status', 'create_time', 'update_time']
read_only_fields = ['id', 'create_time', 'update_time']
def to_representation(self, instance):
"""将tags字符串转换为数组"""
representation = super().to_representation(instance)
if representation.get('tags'):
representation['tags'] = representation['tags'].split(',')
return representation

View File

@ -9,16 +9,13 @@ from django.utils import timezone
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from django.db.models import Q
import os
from .models import OperatorAccount, PlatformAccount, Video
from apps.knowledge_base.models import KnowledgeBase, KnowledgeBaseDocument
from apps.accounts.models import User
from .serializers import (
OperatorAccountSerializer, PlatformAccountSerializer, VideoSerializer,
KnowledgeBaseSerializer, KnowledgeBaseDocumentSerializer
MultiPlatformAccountSerializer
)
from .pagination import CustomPagination
@ -77,118 +74,28 @@ class OperatorAccountViewSet(viewsets.ModelViewSet):
return self.update(request, *args, **kwargs)
def create(self, request, *args, **kwargs):
"""创建运营账号并自动创建对应的私有知识库"""
with transaction.atomic():
# 1. 创建运营账号
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# 2. 手动保存数据而不是使用serializer.save()确保不传入UUID
operator_data = serializer.validated_data
operator = OperatorAccount.objects.create(**operator_data)
# 3. 为每个运营账号创建一个私有知识库
knowledge_base = KnowledgeBase.objects.create(
user_id=request.user.id, # 使用当前用户作为创建者
name=f"{operator.real_name}的运营知识库",
desc=f"用于存储{operator.real_name}({operator.username})相关的运营数据",
type='private',
department=operator.department
)
# 4. 创建知识库文档记录 - 运营信息文档
document_data = {
"name": f"{operator.real_name}_运营信息",
"paragraphs": [
{
"title": "运营账号基本信息",
"content": f"""
用户名: {operator.username}
真实姓名: {operator.real_name}
邮箱: {operator.email}
电话: {operator.phone}
职位: {operator.get_position_display()}
部门: {operator.department}
创建时间: {operator.created_at.strftime('%Y-%m-%d %H:%M:%S')}
uuid: {operator.uuid}
""",
"is_active": True
}
]
}
# 调用外部API创建文档
document_id = self._create_document(knowledge_base.external_id, document_data)
if document_id:
# 创建知识库文档记录
KnowledgeBaseDocument.objects.create(
knowledge_base=knowledge_base,
document_id=document_id,
document_name=document_data["name"],
external_id=document_id,
uploader_name=request.user.username
)
return Response({
"code": 200,
"message": "运营账号创建成功,并已创建对应知识库",
"data": {
"operator": self.get_serializer(operator).data,
"knowledge_base": {
"id": knowledge_base.id,
"name": knowledge_base.name,
"external_id": knowledge_base.external_id
}
}
}, status=status.HTTP_201_CREATED)
"""创建运营账号"""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
return Response({
"code": 200,
"message": "运营账号创建成功",
"data": serializer.data
}, status=status.HTTP_201_CREATED)
def destroy(self, request, *args, **kwargs):
"""删除运营账号并更新相关知识库状态"""
"""删除运营账号"""
operator = self.get_object()
# 更新知识库状态或删除关联文档
knowledge_bases = KnowledgeBase.objects.filter(
name__contains=operator.real_name,
type='private'
)
for kb in knowledge_bases:
# 可以选择删除知识库,或者更新知识库状态
# 这里我们更新对应的文档状态
documents = KnowledgeBaseDocument.objects.filter(
knowledge_base=kb,
document_name__contains=operator.real_name
)
for doc in documents:
doc.status = 'deleted'
doc.save()
operator.is_active = False # 软删除
operator.save()
return Response({
"code": 200,
"message": "运营账号已停用,相关知识库文档已标记为删除",
"message": "运营账号已停用",
"data": None
})
def _create_document(self, external_id, doc_data):
"""调用外部API创建文档"""
try:
if not external_id:
logger.error("创建文档失败知识库external_id为空")
return None
# 在实际应用中这里需要调用外部API创建文档
# 模拟创建文档并返回document_id
document_id = str(uuid.uuid4())
logger.info(f"模拟创建文档成功document_id: {document_id}")
return document_id
except Exception as e:
logger.error(f"创建文档失败: {str(e)}")
return None
class PlatformAccountViewSet(viewsets.ModelViewSet):
@ -204,38 +111,149 @@ class PlatformAccountViewSet(viewsets.ModelViewSet):
if page is not None:
serializer = self.get_serializer(page, many=True)
# 使用自定义分页器的响应
return self.get_paginated_response(serializer.data)
# 处理数据结构
response_data = serializer.data
restructured_data = []
for account_data in response_data:
# 提取平台信息并放入platforms字段
platform_info = {
"platform_name": account_data.pop("platform_name"),
"account_url": account_data.pop("account_url"),
"account_id": account_data.pop("account_id"),
"account_name": account_data.pop("account_name")
}
# 添加platforms字段作为数组
account_data["platforms"] = [platform_info]
# 保留用户传入的name字段如果没有则使用platform_name
if not account_data.get("name"):
account_data["name"] = platform_info["platform_name"]
restructured_data.append(account_data)
# 使用自定义分页器的响应,但替换数据
return self.get_paginated_response(restructured_data)
serializer = self.get_serializer(queryset, many=True)
# 处理数据结构
response_data = serializer.data
restructured_data = []
for account_data in response_data:
# 提取平台信息并放入platforms字段
platform_info = {
"platform_name": account_data.pop("platform_name"),
"account_url": account_data.pop("account_url"),
"account_id": account_data.pop("account_id"),
"account_name": account_data.pop("account_name")
}
# 添加platforms字段作为数组
account_data["platforms"] = [platform_info]
# 保留用户传入的name字段如果没有则使用platform_name
if not account_data.get("name"):
account_data["name"] = platform_info["platform_name"]
restructured_data.append(account_data)
return Response({
"code": 200,
"message": "获取平台账号列表成功",
"data": serializer.data
"data": restructured_data
})
def retrieve(self, request, *args, **kwargs):
"""获取平台账号详情"""
instance = self.get_object()
serializer = self.get_serializer(instance)
# 处理数据结构
account_data = serializer.data
# 提取平台信息并放入platforms字段
platform_info = {
"platform_name": account_data.pop("platform_name"),
"account_url": account_data.pop("account_url"),
"account_id": account_data.pop("account_id"),
"account_name": account_data.pop("account_name")
}
# 添加platforms字段
account_data["platforms"] = [platform_info]
# 保留用户传入的name字段如果没有则使用platform_name
if not account_data.get("name"):
account_data["name"] = platform_info["platform_name"]
return Response({
"code": 200,
"message": "获取平台账号详情成功",
"data": serializer.data
"data": account_data
})
def update(self, request, *args, **kwargs):
"""更新平台账号信息"""
partial = kwargs.pop('partial', False)
instance = self.get_object()
serializer = self.get_serializer(instance, data=request.data, partial=partial)
# 单独处理platforms字段多平台信息需要特殊处理
data = request.data.copy()
platforms_data = None
if 'platforms' in data and isinstance(data['platforms'], list):
platforms_data = data.pop('platforms')
# 如果有platforms数据先将第一个平台的信息移至顶层保证基本平台信息能正常更新
if platforms_data and len(platforms_data) > 0:
first_platform = platforms_data[0]
if 'platform_name' in first_platform:
data['platform_name'] = first_platform['platform_name']
if 'account_url' in first_platform:
data['account_url'] = first_platform['account_url']
if 'account_id' in first_platform:
data['account_id'] = first_platform['account_id']
if 'account_name' in first_platform:
data['account_name'] = first_platform['account_name']
serializer = self.get_serializer(instance, data=data, partial=partial)
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)
# 处理多平台信息
# 这里我们需要实现新的逻辑来处理额外的平台
# 注意:这需要修改模型结构或添加关联模型来支持多平台
# 由于当前模型结构限制,我们暂时只能在此记录其他平台数据
# 用于未来扩展,目前会在日志中记录这些信息
if platforms_data and len(platforms_data) > 1:
logger = logging.getLogger(__name__)
logger.info(f"接收到多平台数据,但当前版本仅支持一个平台。额外平台数据: {platforms_data[1:]}")
# 这里应该添加创建关联平台记录的代码
# 例如:
# for platform_data in platforms_data[1:]:
# RelatedPlatform.objects.create(
# primary_account=instance,
# platform_name=platform_data.get('platform_name', ''),
# account_id=platform_data.get('account_id', ''),
# account_name=platform_data.get('account_name', ''),
# account_url=platform_data.get('account_url', '')
# )
# 处理数据结构
account_data = serializer.data
# 提取平台信息并放入platforms字段
platform_info = {
"platform_name": account_data.pop("platform_name"),
"account_url": account_data.pop("account_url"),
"account_id": account_data.pop("account_id"),
"account_name": account_data.pop("account_name")
}
# 添加platforms字段
# 如果有platforms_data使用原始请求中的数据否则使用当前的单平台数据
if platforms_data:
account_data["platforms"] = platforms_data
else:
account_data["platforms"] = [platform_info]
# 保留用户传入的name字段如果没有则使用platform_name
if not account_data.get("name"):
account_data["name"] = platform_info["platform_name"]
return Response({
"code": 200,
"message": "更新平台账号信息成功",
"data": serializer.data
"data": account_data
})
def partial_update(self, request, *args, **kwargs):
@ -244,7 +262,9 @@ class PlatformAccountViewSet(viewsets.ModelViewSet):
return self.update(request, *args, **kwargs)
def create(self, request, *args, **kwargs):
"""创建平台账号并记录到知识库"""
"""创建平台账号"""
# 传统单平台账号创建流程
print(f"{request.data}")
with transaction.atomic():
# 处理operator字段可能是字符串类型的ID
data = request.data.copy()
@ -283,118 +303,44 @@ class PlatformAccountViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)
# 手动创建平台账号不使用serializer.save()避免ID问题
platform_data = serializer.validated_data
platform_account = PlatformAccount.objects.create(**platform_data)
# 创建平台账号
self.perform_create(serializer)
# 获取关联的运营账号
operator = platform_account.operator
# 查找对应的知识库
knowledge_base = KnowledgeBase.objects.filter(
name__contains=operator.real_name,
type='private'
).first()
if knowledge_base and knowledge_base.external_id:
# 创建平台账号文档
document_data = {
"name": f"{platform_account.account_name}_{platform_account.platform_name}_账号信息",
"paragraphs": [
{
"title": "平台账号基本信息",
"content": f"""
平台: {platform_account.get_platform_name_display()}
账号名称: {platform_account.account_name}
账号ID: {platform_account.account_id}
账号状态: {platform_account.get_status_display()}
粉丝数: {platform_account.followers_count}
账号链接: {platform_account.account_url}
账号描述: {platform_account.description or ''}
标签: {platform_account.tags or ''}
头像链接: {platform_account.profile_image or ''}
最后发布时间: {platform_account.last_posting.strftime('%Y-%m-%d %H:%M:%S') if platform_account.last_posting else '未发布'}
创建时间: {platform_account.created_at.strftime('%Y-%m-%d %H:%M:%S')}
最后登录: {platform_account.last_login.strftime('%Y-%m-%d %H:%M:%S') if platform_account.last_login else '从未登录'}
""",
"is_active": True
}
]
}
# 调用外部API创建文档
document_id = self._create_document(knowledge_base.external_id, document_data)
if document_id:
# 创建知识库文档记录
KnowledgeBaseDocument.objects.create(
knowledge_base=knowledge_base,
document_id=document_id,
document_name=document_data["name"],
external_id=document_id,
uploader_name=request.user.username
)
# 处理响应数据
account_data = serializer.data
# 提取平台信息并放入platforms字段
platform_info = {
"platform_name": account_data.pop("platform_name"),
"account_url": account_data.pop("account_url"),
"account_id": account_data.pop("account_id"),
"account_name": account_data.pop("account_name")
}
# 添加platforms字段
account_data["platforms"] = [platform_info]
# 保留用户传入的name字段如果没有则使用platform_name
if not account_data.get("name"):
account_data["name"] = platform_info["platform_name"]
return Response({
"code": 200,
"message": "平台账号创建成功,并已添加到知识库",
"data": self.get_serializer(platform_account).data
"message": "平台账号创建成功",
"data": account_data
}, status=status.HTTP_201_CREATED)
def destroy(self, request, *args, **kwargs):
"""删除平台账号并更新相关知识库文档"""
"""删除平台账号"""
platform_account = self.get_object()
# 获取关联的运营账号
operator = platform_account.operator
# 查找对应的知识库
knowledge_base = KnowledgeBase.objects.filter(
name__contains=operator.real_name,
type='private'
).first()
if knowledge_base:
# 查找相关文档并标记为删除
documents = KnowledgeBaseDocument.objects.filter(
knowledge_base=knowledge_base
).filter(
Q(document_name__contains=platform_account.account_name) |
Q(document_name__contains=platform_account.platform_name)
)
for doc in documents:
doc.status = 'deleted'
doc.save()
# 删除平台账号
self.perform_destroy(platform_account)
return Response({
"code": 200,
"message": "平台账号已删除,相关知识库文档已标记为删除",
"message": "平台账号已删除",
"data": None
})
def _create_document(self, external_id, doc_data):
"""调用外部API创建文档"""
try:
if not external_id:
logger.error("创建文档失败知识库external_id为空")
return None
# 在实际应用中这里需要调用外部API创建文档
# 模拟创建文档并返回document_id
document_id = str(uuid.uuid4())
logger.info(f"模拟创建文档成功document_id: {document_id}")
return document_id
except Exception as e:
logger.error(f"创建文档失败: {str(e)}")
return None
@action(detail=True, methods=['post'])
def update_followers(self, request, pk=None):
"""更新平台账号粉丝数并同步到知识库"""
"""更新平台账号粉丝数"""
platform_account = self.get_object()
followers_count = request.data.get('followers_count')
@ -409,36 +355,25 @@ class PlatformAccountViewSet(viewsets.ModelViewSet):
platform_account.followers_count = followers_count
platform_account.save()
# 同步到知识库
operator = platform_account.operator
knowledge_base = KnowledgeBase.objects.filter(
name__contains=operator.real_name,
type='private'
).first()
if knowledge_base:
# 查找相关文档
document = KnowledgeBaseDocument.objects.filter(
knowledge_base=knowledge_base,
status='active'
).filter(
Q(document_name__contains=platform_account.account_name) |
Q(document_name__contains=platform_account.platform_name)
).first()
if document:
# 这里应该调用外部API更新文档内容
# 但由于我们没有实际的API只做记录
logger.info(f"应当更新文档 {document.document_id} 的粉丝数为 {followers_count}")
# 准备响应数据,与其他方法保持一致
platform_data = self.get_serializer(platform_account).data
# 提取平台信息并放入platforms字段
platform_info = {
"platform_name": platform_data.pop("platform_name"),
"account_url": platform_data.pop("account_url"),
"account_id": platform_data.pop("account_id"),
"account_name": platform_data.pop("account_name")
}
# 添加platforms字段
platform_data["platforms"] = [platform_info]
# 保留用户传入的name字段如果没有则使用platform_name
if not platform_data.get("name"):
platform_data["name"] = platform_info["platform_name"]
return Response({
"code": 200,
"message": "粉丝数更新成功",
"data": {
"id": platform_account.id,
"account_name": platform_account.account_name,
"followers_count": platform_account.followers_count
}
"data": platform_data
})
@action(detail=True, methods=['post'])
@ -451,7 +386,12 @@ class PlatformAccountViewSet(viewsets.ModelViewSet):
# 处理标签
if 'tags' in request.data:
profile_data['tags'] = request.data['tags']
# 处理tags支持字符串或数组格式
tags = request.data['tags']
if isinstance(tags, list):
profile_data['tags'] = ','.join(tags)
else:
profile_data['tags'] = tags
# 处理头像
if 'profile_image' in request.data:
@ -470,6 +410,10 @@ class PlatformAccountViewSet(viewsets.ModelViewSet):
"message": f"最后发布时间格式错误: {str(e)}",
"data": None
}, status=status.HTTP_400_BAD_REQUEST)
# 处理名称
if 'name' in request.data:
profile_data['name'] = request.data['name']
if not profile_data:
return Response({
@ -483,30 +427,25 @@ class PlatformAccountViewSet(viewsets.ModelViewSet):
setattr(platform_account, field, value)
platform_account.save()
# 同步到知识库
# 在实际应用中应该调用外部API更新文档内容
operator = platform_account.operator
knowledge_base = KnowledgeBase.objects.filter(
name__contains=operator.real_name,
type='private'
).first()
if knowledge_base:
document = KnowledgeBaseDocument.objects.filter(
knowledge_base=knowledge_base,
status='active'
).filter(
Q(document_name__contains=platform_account.account_name) |
Q(document_name__contains=platform_account.platform_name)
).first()
if document:
logger.info(f"应当更新文档 {document.document_id} 的平台账号资料数据")
# 准备响应数据,与其他方法保持一致
platform_data = self.get_serializer(platform_account).data
# 提取平台信息并放入platforms字段
platform_info = {
"platform_name": platform_data.pop("platform_name"),
"account_url": platform_data.pop("account_url"),
"account_id": platform_data.pop("account_id"),
"account_name": platform_data.pop("account_name")
}
# 添加platforms字段
platform_data["platforms"] = [platform_info]
# 保留用户传入的name字段如果没有则使用platform_name
if not platform_data.get("name"):
platform_data["name"] = platform_info["platform_name"]
return Response({
"code": 200,
"message": "平台账号资料更新成功",
"data": self.get_serializer(platform_account).data
"data": platform_data
})
@ -563,7 +502,7 @@ class VideoViewSet(viewsets.ModelViewSet):
return self.update(request, *args, **kwargs)
def create(self, request, *args, **kwargs):
"""创建视频并记录到知识库"""
"""创建视频"""
with transaction.atomic():
# 处理platform_account字段可能是字符串类型的ID
data = request.data.copy()
@ -602,117 +541,29 @@ class VideoViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)
# 手动创建视频不使用serializer.save()避免ID问题
video_data = serializer.validated_data
video = Video.objects.create(**video_data)
# 获取关联的平台账号和运营账号
platform_account = video.platform_account
operator = platform_account.operator
# 查找对应的知识库
knowledge_base = KnowledgeBase.objects.filter(
name__contains=operator.real_name,
type='private'
).first()
if knowledge_base and knowledge_base.external_id:
# 创建视频文档
document_data = {
"name": f"{video.title}_{platform_account.account_name}_视频信息",
"paragraphs": [
{
"title": "视频基本信息",
"content": f"""
标题: {video.title}
平台: {platform_account.get_platform_name_display()}
账号: {platform_account.account_name}
视频ID: {video.video_id}
发布时间: {video.publish_time.strftime('%Y-%m-%d %H:%M:%S') if video.publish_time else '未发布'}
视频链接: {video.video_url}
点赞数: {video.likes_count}
评论数: {video.comments_count}
分享数: {video.shares_count}
观看数: {video.views_count}
视频描述: {video.description or ''}
""",
"is_active": True
}
]
}
# 调用外部API创建文档
document_id = self._create_document(knowledge_base.external_id, document_data)
if document_id:
# 创建知识库文档记录
KnowledgeBaseDocument.objects.create(
knowledge_base=knowledge_base,
document_id=document_id,
document_name=document_data["name"],
external_id=document_id,
uploader_name=request.user.username
)
# 创建视频
self.perform_create(serializer)
return Response({
"code": 200,
"message": "视频创建成功,并已添加到知识库",
"data": self.get_serializer(video).data
"message": "视频创建成功",
"data": serializer.data
}, status=status.HTTP_201_CREATED)
def destroy(self, request, *args, **kwargs):
"""删除视频记录并更新相关知识库文档"""
"""删除视频记录"""
video = self.get_object()
# 获取关联的平台账号和运营账号
platform_account = video.platform_account
operator = platform_account.operator
# 查找对应的知识库
knowledge_base = KnowledgeBase.objects.filter(
name__contains=operator.real_name,
type='private'
).first()
if knowledge_base:
# 查找相关文档并标记为删除
documents = KnowledgeBaseDocument.objects.filter(
knowledge_base=knowledge_base,
document_name__contains=video.title
)
for doc in documents:
doc.status = 'deleted'
doc.save()
# 删除视频记录
self.perform_destroy(video)
return Response({
"code": 200,
"message": "视频记录已删除,相关知识库文档已标记为删除",
"message": "视频记录已删除",
"data": None
})
def _create_document(self, external_id, doc_data):
"""调用外部API创建文档"""
try:
if not external_id:
logger.error("创建文档失败知识库external_id为空")
return None
# 在实际应用中这里需要调用外部API创建文档
# 模拟创建文档并返回document_id
document_id = str(uuid.uuid4())
logger.info(f"模拟创建文档成功document_id: {document_id}")
return document_id
except Exception as e:
logger.error(f"创建文档失败: {str(e)}")
return None
@action(detail=True, methods=['post'])
def update_stats(self, request, pk=None):
"""更新视频统计数据并同步到知识库"""
"""更新视频统计数据"""
video = self.get_object()
# 获取更新的统计数据
@ -733,25 +584,6 @@ class VideoViewSet(viewsets.ModelViewSet):
setattr(video, field, value)
video.save()
# 同步到知识库
# 在实际应用中应该调用外部API更新文档内容
platform_account = video.platform_account
operator = platform_account.operator
knowledge_base = KnowledgeBase.objects.filter(
name__contains=operator.real_name,
type='private'
).first()
if knowledge_base:
document = KnowledgeBaseDocument.objects.filter(
knowledge_base=knowledge_base,
document_name__contains=video.title,
status='active'
).first()
if document:
logger.info(f"应当更新文档 {document.document_id} 的视频统计数据")
return Response({
"code": 200,
"message": "视频统计数据更新成功",
@ -793,25 +625,6 @@ class VideoViewSet(viewsets.ModelViewSet):
video.publish_time = timezone.now()
video.save()
# 同步到知识库
# 在实际应用中应该调用外部API更新文档内容
platform_account = video.platform_account
operator = platform_account.operator
knowledge_base = KnowledgeBase.objects.filter(
name__contains=operator.real_name,
type='private'
).first()
if knowledge_base:
document = KnowledgeBaseDocument.objects.filter(
knowledge_base=knowledge_base,
document_name__contains=video.title,
status='active'
).first()
if document:
logger.info(f"应当更新文档 {document.document_id} 的视频发布状态")
return Response({
"code": 200,
"message": "视频已成功发布",
@ -890,31 +703,9 @@ class VideoViewSet(viewsets.ModelViewSet):
'tags': request.data.get('tags', '')
}
# 如果提供了计划发布时间,则设置状态为已排期
scheduled_time = request.data.get('scheduled_time')
if scheduled_time:
from dateutil import parser
try:
parsed_time = parser.parse(scheduled_time)
video_data['scheduled_time'] = parsed_time
video_data['status'] = 'scheduled'
except Exception as e:
return Response({
"code": 400,
"message": f"计划发布时间格式错误: {str(e)}",
"data": None
}, status=status.HTTP_400_BAD_REQUEST)
# 创建视频记录
video = Video.objects.create(**video_data)
# 添加到知识库
self._add_to_knowledge_base(video, platform_account)
# 如果是已排期状态,创建定时任务
if video.status == 'scheduled':
self._create_publish_task(video)
return Response({
"code": 200,
"message": "视频上传成功",
@ -922,7 +713,6 @@ class VideoViewSet(viewsets.ModelViewSet):
"id": video.id,
"title": video.title,
"status": video.get_status_display(),
"scheduled_time": video.scheduled_time
}
}, status=status.HTTP_201_CREATED)
@ -934,87 +724,6 @@ class VideoViewSet(viewsets.ModelViewSet):
"data": None
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def _add_to_knowledge_base(self, video, platform_account):
"""将视频添加到知识库"""
# 获取关联的运营账号
operator = platform_account.operator
# 查找对应的知识库
knowledge_base = KnowledgeBase.objects.filter(
name__contains=operator.real_name,
type='private'
).first()
if knowledge_base and knowledge_base.external_id:
# 创建视频文档
document_data = {
"name": f"{video.title}_{platform_account.account_name}_视频信息",
"paragraphs": [
{
"title": "视频基本信息",
"content": f"""
标题: {video.title}
平台: {platform_account.get_platform_name_display()}
账号: {platform_account.account_name}
状态: {video.get_status_display()}
本地路径: {video.local_path}
计划发布时间: {video.scheduled_time.strftime('%Y-%m-%d %H:%M:%S') if video.scheduled_time else '未设置'}
视频描述: {video.description or ''}
标签: {video.tags or ''}
创建时间: {video.created_at.strftime('%Y-%m-%d %H:%M:%S')}
""",
"is_active": True
}
]
}
# 调用外部API创建文档
document_id = self._create_document(knowledge_base.external_id, document_data)
if document_id:
# 创建知识库文档记录
KnowledgeBaseDocument.objects.create(
knowledge_base=knowledge_base,
document_id=document_id,
document_name=document_data["name"],
external_id=document_id,
uploader_name="系统"
)
def _create_publish_task(self, video):
"""创建定时发布任务"""
try:
from django_celery_beat.models import PeriodicTask, CrontabSchedule
import json
from datetime import datetime
scheduled_time = video.scheduled_time
# 创建定时任务
schedule, _ = CrontabSchedule.objects.get_or_create(
minute=scheduled_time.minute,
hour=scheduled_time.hour,
day_of_month=scheduled_time.day,
month_of_year=scheduled_time.month,
)
# 创建周期性任务
task_name = f"Publish_Video_{video.id}_{datetime.now().timestamp()}"
PeriodicTask.objects.create(
name=task_name,
task='user_management.tasks.publish_scheduled_video',
crontab=schedule,
args=json.dumps([video.id]),
one_off=True, # 只执行一次
start_time=scheduled_time
)
logger.info(f"已创建视频 {video.id} 的定时发布任务,计划发布时间: {scheduled_time}")
except Exception as e:
logger.error(f"创建定时发布任务失败: {str(e)}")
# 记录错误但不中断流程
@action(detail=True, methods=['post'])
def manual_publish(self, request, pk=None):
"""手动发布视频"""
@ -1029,51 +738,32 @@ class VideoViewSet(viewsets.ModelViewSet):
}, status=status.HTTP_400_BAD_REQUEST)
# 检查视频文件是否存在
if not video.local_path or not os.path.exists(video.local_path):
if video.local_path and not os.path.exists(video.local_path):
return Response({
"code": 400,
"message": "视频文件不存在,无法发布",
"data": None
}, status=status.HTTP_400_BAD_REQUEST)
# 自动发布 - 不依赖Celery任务
try:
# 模拟上传到平台
platform_account = video.platform_account
platform_name = platform_account.platform_name
# 获取视频URL如果没有提供则创建一个模拟的URL
video_url = request.data.get('video_url')
# 创建模拟的视频URL和ID
video_url = f"https://example.com/{platform_name}/{video.id}"
video_id = f"VID_{video.id}"
if not video_url:
# 创建模拟的视频URL和ID
platform_account = video.platform_account
platform_name = platform_account.platform_name
video_url = f"https://example.com/{platform_name}/{video.id}"
# 更新视频状态
video.status = 'published'
video.publish_time = timezone.now()
video.video_url = video_url
video.video_id = video_id
video.video_id = f"VID_{video.id}"
video.save()
logger.info(f"视频 {video.id} 已手动发布")
# 更新知识库文档
platform_account = video.platform_account
operator = platform_account.operator
knowledge_base = KnowledgeBase.objects.filter(
name__contains=operator.real_name,
type='private'
).first()
if knowledge_base:
document = KnowledgeBaseDocument.objects.filter(
knowledge_base=knowledge_base,
document_name__contains=video.title,
status='active'
).first()
if document:
logger.info(f"应当更新文档 {document.document_id} 的视频发布状态")
return Response({
"code": 200,
"message": "视频发布成功",

0
apps/template/admin.py Normal file
View File

7
apps/template/apps.py Normal file
View 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 = '模板管理'

View 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
View 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'
]

View 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': '模板',
},
),
]

View File

77
apps/template/models.py Normal file
View 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)

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

View 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
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

11
apps/template/urls.py Normal file
View 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
View 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
View 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="按服务类型获取模板成功"
)

View File

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

View File

@ -1,4 +1,4 @@
"""
"""
URL configuration for daren_project project.
The `urlpatterns` list routes URLs to views. For more information please see:
@ -25,7 +25,9 @@ urlpatterns = [
path('api/permissions/', include('apps.permissions.urls')),
path('api/notification/', include('apps.notification.urls')),
path('api/gmail/', include('apps.gmail.urls')),
# path('api/feishu/', include('apps.feishu.urls')),
path('api/feishu/', include('apps.feishu.urls')),
path('api/', include('apps.brands.urls')),
path('api/operation/', include('apps.operation.urls')),
path('api/discovery/', include('apps.discovery.urls')),
path('api/templates/', include('apps.template.urls')),
]

Binary file not shown.