diff --git a/README_VIDEO_UPLOAD.md b/README_VIDEO_UPLOAD.md new file mode 100644 index 0000000..988cee5 --- /dev/null +++ b/README_VIDEO_UPLOAD.md @@ -0,0 +1,105 @@ +# 视频上传和定时发布功能使用说明 + +本文档介绍如何使用系统提供的视频上传和定时发布功能。 + +## 前提条件 + +1. 已安装所有依赖包: + ``` + pip install -r requirements.txt + ``` + +2. 确保Redis服务已启动(用于Celery任务队列) + ``` + redis-server + ``` + +3. 启动Celery Worker(处理后台任务) + ``` + celery -A role_based_system worker -l info + ``` + +4. 启动Celery Beat(处理定时任务) + ``` + celery -A role_based_system beat -l info + ``` + +## 方法一:通过命令行测试 + +### 准备平台账号 +首先,确保系统中已存在至少一个平台账号。可以通过admin界面或API创建。 + +### 上传视频并设置定时发布 +使用以下命令上传视频并计划发布: + +```bash +python manage.py test_video_upload "D:\pythonproject\role_based\role_based_system\上传视频测试.mp4" 1 --title "测试视频标题" --desc "这是一个测试视频描述" --schedule "2023-08-10 15:30:00" +``` + +参数说明: +- 第一个参数:视频文件路径 +- 第二个参数:平台账号ID +- `--title`:视频标题(可选,默认使用文件名) +- `--desc`:视频描述(可选) +- `--schedule`:计划发布时间(可选,格式:YYYY-MM-DD HH:MM:SS) + +### 手动发布视频 +如果要立即发布视频,可以使用以下命令: + +```bash +python manage.py publish_video 1 +``` + +参数说明: +- 视频ID + +## 方法二:通过API接口 + +### 上传视频 +使用POST请求上传视频: + +``` +POST /api/operation/videos/upload_video/ +``` + +表单数据: +- `video_file`:视频文件 +- `platform_account`:平台账号ID +- `title`:视频标题(可选) +- `description`:视频描述(可选) +- `scheduled_time`:计划发布时间(可选,ISO格式) +- `tags`:标签(可选) + +### 手动发布视频 +使用POST请求立即发布视频: + +``` +POST /api/operation/videos/{video_id}/manual_publish/ +``` + +## 如何验证视频是否成功发布 + +1. 检查视频记录状态: + ``` + python manage.py shell + >>> from user_management.models import Video + >>> Video.objects.get(id=1).status + 'published' + ``` + +2. 查看日志记录: + ``` + tail -f debug.log + ``` + +3. 通过API查询视频状态: + ``` + GET /api/operation/videos/{video_id}/ + ``` + +## 注意事项 + +1. 视频文件会被保存在媒体目录(默认为 `media/videos/{platform_name}_{account_name}/`) +2. 定时发布功能依赖于Celery和Redis,确保这些服务正常运行 +3. 本系统目前仅模拟视频发布过程,实际发布到各平台需要扩展相关API +4. 默认视频状态流转:draft(草稿)-> scheduled(已排期)-> published(已发布)或 failed(失败) \ No newline at end of file diff --git a/celerybeat-schedule.bak b/celerybeat-schedule.bak new file mode 100644 index 0000000..5f22ecd --- /dev/null +++ b/celerybeat-schedule.bak @@ -0,0 +1,4 @@ +'entries', (0, 428) +'__version__', (512, 20) +'tz', (1024, 28) +'utc_enabled', (1536, 4) diff --git a/celerybeat-schedule.dat b/celerybeat-schedule.dat new file mode 100644 index 0000000..633435c Binary files /dev/null and b/celerybeat-schedule.dat differ diff --git a/celerybeat-schedule.dir b/celerybeat-schedule.dir new file mode 100644 index 0000000..5f22ecd --- /dev/null +++ b/celerybeat-schedule.dir @@ -0,0 +1,4 @@ +'entries', (0, 428) +'__version__', (512, 20) +'tz', (1024, 28) +'utc_enabled', (1536, 4) diff --git a/feishu/feishu_ai_chat.py b/feishu/feishu_ai_chat.py index 9aa1187..391a9df 100644 --- a/feishu/feishu_ai_chat.py +++ b/feishu/feishu_ai_chat.py @@ -9,6 +9,7 @@ import django from django.db import transaction from django.contrib.auth import get_user_model import time +import pandas as pd # 设置Django环境 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -247,7 +248,7 @@ def extract_field_value(field_value): def find_duplicate_email_creators(records): """ - 查找记录中重复邮箱的创作者 + 查找记录中与数据库中FeishuCreator表匹配的邮箱 参数: records: 飞书记录列表 @@ -255,22 +256,23 @@ def find_duplicate_email_creators(records): 返回: dict: 以邮箱为键,记录列表为值的字典 """ - email_map = {} + matching_emails = {} for record in records: fields = record.fields email = extract_field_value(fields.get('邮箱', '')) if email: - if email not in email_map: - email_map[email] = [] - email_map[email].append(record) + # 检查邮箱是否存在于FeishuCreator表中 + creator = FeishuCreator.objects.filter(email=email).first() + if creator: + if email not in matching_emails: + matching_emails[email] = [] + matching_emails[email].append(record) + logger.info(f"找到匹配的creator邮箱: {email}, creator_id: {creator.id}") - # 过滤出现次数>1的邮箱 - duplicate_emails = {email: records for email, records in email_map.items() if len(records) > 1} - - logger.info(f"发现 {len(duplicate_emails)} 个重复邮箱") - return duplicate_emails + logger.info(f"共发现 {len(matching_emails)} 个与FeishuCreator表匹配的邮箱") + return matching_emails def create_or_update_knowledge_base(email, user=None): """ @@ -415,13 +417,14 @@ def create_chat_with_ai(user, talent_email, goal_content): 'message': str(e) } -def process_duplicate_emails(duplicate_emails, goal_content=None): +def process_duplicate_emails(matching_emails, goal_content=None, auto_reply=False): """ - 处理重复邮箱记录 + 处理匹配到的creator邮箱记录 参数: - duplicate_emails: 重复邮箱记录字典 + matching_emails: 匹配到的邮箱记录字典 goal_content: 目标内容模板,可包含{email}和{handle}占位符 + auto_reply: 是否自动设置Gmail回复 返回: dict: 处理结果统计 @@ -440,13 +443,14 @@ def process_duplicate_emails(duplicate_emails, goal_content=None): } results = { - 'total': len(duplicate_emails), + 'total': len(matching_emails), 'success': 0, 'failure': 0, + 'auto_reply_setup': 0, # 增加自动回复设置计数 'details': [] } - for email, records in duplicate_emails.items(): + for email, records in matching_emails.items(): try: # 获取一个Handle作为示例 handle = extract_field_value(records[0].fields.get('Handle', email.split('@')[0])) @@ -460,6 +464,35 @@ def process_duplicate_emails(duplicate_emails, goal_content=None): if result['status'] == 'success': results['success'] += 1 logger.info(f"成功为邮箱 {email} 创建AI聊天") + + # 如果启用自动回复,设置Gmail监听并开始自动对话 + if auto_reply: + try: + # 调用auto_chat_session开始自动对话 + auto_chat_result = auto_chat_session(leader, email) + + if auto_chat_result['status'] == 'success': + results['auto_reply_setup'] += 1 + logger.info(f"成功为邮箱 {email} 设置自动回复") + # 在详情中添加自动回复信息 + result['auto_reply'] = { + 'status': 'success', + 'message': auto_chat_result.get('message', '已设置自动回复'), + 'conversation_id': auto_chat_result.get('conversation_id') + } + else: + logger.error(f"为邮箱 {email} 设置自动回复失败: {auto_chat_result.get('message', 'Unknown error')}") + result['auto_reply'] = { + 'status': 'error', + 'message': auto_chat_result.get('message', '设置自动回复失败') + } + except Exception as ar_e: + logger.error(f"为邮箱 {email} 设置自动回复时出错: {str(ar_e)}") + logger.error(traceback.format_exc()) + result['auto_reply'] = { + 'status': 'error', + 'message': str(ar_e) + } else: results['failure'] += 1 logger.error(f"为邮箱 {email} 创建AI聊天失败: {result.get('message', 'Unknown error')}") @@ -486,99 +519,118 @@ def process_duplicate_emails(duplicate_emails, goal_content=None): def generate_ai_response(conversation_history, user_goal): """ 调用DeepSeek API生成AI响应 - + 参数: conversation_history: 对话历史 user_goal: 用户总目标 - + 返回: str: AI响应内容 """ try: # 使用有效的API密钥 api_key = "sk-xqbujijjqqmlmlvkhvxeogqjtzslnhdtqxqgiyuhwpoqcjvf" - # 如果上面的密钥不正确,可以尝试从环境变量或数据库中获取 - # 从Django设置中获取密钥 if hasattr(settings, 'DEEPSEEK_API_KEY') and settings.DEEPSEEK_API_KEY: api_key = settings.DEEPSEEK_API_KEY - + url = "https://api.siliconflow.cn/v1/chat/completions" - - # 系统消息指定AI助手的角色和总目标 + system_message = { - "role": "system", - "content": f"""你是一位专业的电商客服和达人助手。你的任务是与达人进行对话,帮助实现以下总目标: - -{user_goal} - -你应该主动推进对话,引导达人朝着目标方向发展。每次回复应该简洁明了,专业且有帮助。 -如果你认为总目标已经达成,请在回复的最后一行添加标记: [目标已达成]""" + "role": "system", + "content": "你是一位专业的电商客服和达人助手。你的任务是与达人沟通合作事宜,促成品牌合作。回复简洁专业。" } - - messages = [system_message] - - # 添加对话历史,但限制消息数量避免超出token限制 - # 如果对话历史太长,可能需要进一步处理或分割 - if len(conversation_history) > 20: - # 选取关键消息:第一条、最后几条以及中间的一些重要消息 - selected_messages = ( - conversation_history[:2] + # 前两条 - conversation_history[len(conversation_history)//2-2:len(conversation_history)//2+2] + # 中间四条 - conversation_history[-12:] # 最后12条 - ) - messages.extend(selected_messages) - else: - messages.extend(conversation_history) - - # 构建API请求 + goal_message = { + "role": "system", + "content": f"总目标: {user_goal}" + } + + formatted_messages = [system_message, goal_message] + + recent_history = conversation_history[-8:] if len(conversation_history) > 8 else conversation_history + for msg in recent_history: + role = msg.get('role', 'user') + if role not in ['user', 'assistant', 'system']: + role = 'user' if role == 'user' else 'assistant' + + content = msg.get('content', '').strip() + if content: + formatted_messages.append({ + "role": role, + "content": content + }) + + if len(formatted_messages) <= 2: + formatted_messages.append({ + "role": "user", + "content": """Paid Collaboration Opportunity with TikTok's #1 Fragrance Brand 🌸 +Hi, +I'm Vira from OOIN Media, and I'm reaching out on behalf of a top-performing fragrance brand Sttes on TikTok Shop—currently ranked #1 in the perfume category. +This brand has already launched several viral products and is now looking to partner with select creators like you through paid collaborations to continue driving awareness and sales. +We'd love to explore a partnership and would appreciate it if you could share: +- Your rate for a single TikTok video +- Whether you offer bundle pricing for multiple videos +- Any additional details or formats you offer (e.g. story integration, livestream add-ons, etc.) +The product has strong market traction, proven conversions, and a competitive commission structure if you're also open to affiliate partnerships. +Looking forward to the opportunity to work together and hearing your rates! + +Warm regards, +Vira +OOIN Media""" + }) + payload = { "model": "deepseek-ai/DeepSeek-V3", - "messages": messages, + "messages": formatted_messages, "stream": False, - "max_tokens": 1500, # 增加token上限以容纳完整回复 - "temperature": 0.3, # 降低随机性,使回复更加确定性 - "top_p": 0.9, + "max_tokens": 512, + "temperature": 0.7, + "top_p": 0.7, "top_k": 50, "frequency_penalty": 0.5, - "presence_penalty": 0.2, "n": 1, - "stop": [], - "response_format": { - "type": "text" - } + "stop": [] } - + headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {api_key}" + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" } - + + debug_messages = [] + for msg in formatted_messages: + content = msg.get('content', '') + if len(content) > 50: + content = content[:50] + "..." + debug_messages.append({"role": msg.get('role'), "content": content}) + + logger.info(f"DeepSeek API请求消息: {debug_messages}") logger.info("开始调用DeepSeek API生成对话回复") - response = requests.post(url, json=payload, headers=headers) - + + response = requests.request("POST", url, json=payload, headers=headers) + if response.status_code != 200: logger.error(f"DeepSeek API调用失败: {response.status_code}, {response.text}") - return None - + return "AI生成失败,请稍后重试" + result = response.json() logger.debug(f"DeepSeek API返回: {result}") - - # 提取回复内容 + if 'choices' in result and len(result['choices']) > 0: reply = result['choices'][0]['message']['content'] - # 如果返回的内容为空,直接返回None if not reply or reply.strip() == '': logger.warning("DeepSeek API返回的回复内容为空") - return None + return "AI生成失败,请稍后重试" + logger.info(f"DeepSeek API成功生成回复: {reply[:50]}...") return reply - + logger.warning(f"DeepSeek API返回格式异常: {result}") - return None - + return "AI生成失败,请稍后重试" + except Exception as e: logger.error(f"调用DeepSeek API生成回复失败: {str(e)}") logger.error(traceback.format_exc()) - return None + return "AI生成失败,请稍后重试" + def check_goal_achieved(response): """ @@ -621,8 +673,36 @@ def auto_chat_session(user, talent_email, max_turns=10): dict: 会话结果 """ try: + # 从用户关联的Gmail凭证中查找有效的凭证 + from user_management.models import GmailCredential + + # 查找用户的有效Gmail凭证,优先使用默认账户 + gmail_credential = GmailCredential.objects.filter( + user=user, + is_active=True, + is_default=True + ).first() + + # 如果没有默认账户,使用任何一个有效账户 + if not gmail_credential: + gmail_credential = GmailCredential.objects.filter( + user=user, + is_active=True + ).first() + + if gmail_credential: + gmail_credential_id = str(gmail_credential.id) + logger.info(f"找到用户 {user.email} 的Gmail凭证,ID: {gmail_credential.id},Gmail: {gmail_credential.gmail_email or '未知'},名称: {gmail_credential.name}") + else: + logger.warning(f"用户 {user.email} 没有有效的Gmail凭证") + gmail_credential_id = None + # 1. 获取用户目标 - gmail_integration = GmailIntegration(user) + gmail_integration = GmailIntegration( + user=user, + gmail_credential_id=gmail_credential_id + ) + goal_result = gmail_integration.manage_user_goal() if goal_result['status'] != 'success' or not goal_result.get('goal'): @@ -684,54 +764,125 @@ def auto_chat_session(user, talent_email, max_turns=10): # 发送第一封邮件来开始对话 first_subject = "关于合作的洽谈" - first_content = f"您好,\n\n我是{user.username},我们正在寻找合适的达人合作伙伴,注意到您的账号非常适合我们的产品。\n\n请问您有兴趣了解更多关于我们合作的细节吗?\n\n期待您的回复。\n\n祝好,\n{user.username}" + first_content = f"Paid Collaboration Opportunity with TikTok's #1 Fragrance Brand 🌸\nHi,\nI'm Vira from OOIN Media, and I'm reaching out on behalf of a top-performing fragrance brand Sttes on TikTok Shop—currently ranked #1 in the perfume category.\nThis 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.\nWe'd love to explore a partnership and would appreciate it if you could share:\nYour rate for a single TikTok video\nWhether you offer bundle pricing for multiple videos\nAny additional details or formats you offer (e.g. story integration, livestream add-ons, etc.)\nThe product has strong market traction, proven conversions, and a competitive commission structure if you're also open to affiliate partnerships.\nLooking forward to the opportunity to work together and hearing your rates!\nWarm regards,\nVira \nOOIN Media" # 记录首次发送消息到对话历史 initial_msg = ChatHistory.objects.create( user=user, knowledge_base=kb, conversation_id=conversation_id, - role='assistant', + role='user', # 修正:系统发送的首次消息,角色是user content=first_content ) conversation_history.append({ - "role": "assistant", + "role": "user", # 修正:系统发送的首次消息,角色是user "content": initial_msg.content }) # 实际发送邮件 - email_result = gmail_integration.send_email( - to_email=talent_email, - subject=first_subject, - body=first_content, - conversation_id=conversation_id - ) - - if email_result['status'] != 'success': - logger.error(f"发送首次邮件失败: {email_result.get('message', 'Unknown error')}") + try: + email_result = gmail_integration.send_email( + to_email=talent_email, + subject=first_subject, + body=first_content, + conversation_id=conversation_id + ) + + gmail_account = gmail_credential.gmail_email if gmail_credential and gmail_credential.gmail_email else "未设置" + logger.info(f"已发送首次邮件到 {talent_email},使用Gmail账号: {gmail_account},消息ID: {email_result}") + + # 设置Gmail监听,确保能接收到达人的回复 + gmail_integration.setup_watch() + gmail_account = gmail_credential.gmail_email if gmail_credential and gmail_credential.gmail_email else "未设置" + logger.info(f"已为Gmail账号 {gmail_account} 设置监听") + + return { + 'status': 'success', + 'message': '已发送首次邮件并设置Gmail监听,等待达人回复', + 'turns_completed': 1, + 'goal_achieved': False, + 'email_sent': True, + 'conversation_id': conversation_id + } + + except Exception as e: + logger.error(f"发送首次邮件失败: {str(e)}") return { 'status': 'error', - 'message': f"发送首次邮件失败: {email_result.get('message', 'Unknown error')}" + 'message': f"发送首次邮件失败: {str(e)}" } - - logger.info(f"已发送首次邮件到 {talent_email}") - - # 首次邮件发送后,直接返回不进行后续对话,等待达人回复 - return { - 'status': 'success', - 'message': '已发送首次邮件,等待达人回复', - 'turns_completed': 1, - 'goal_achieved': False, - 'email_sent': True, - 'conversation_id': conversation_id - } - # 4. 检查最新消息是否来自达人(user),如果是,才进行回复 + # 4. 检查最新消息是否来自达人(role=assistant),如果是,则生成AI回复 last_message = chat_messages.last() - if last_message and last_message.role == 'user': - # 有达人回复,生成AI回复并发送邮件 - # 生成AI回复 + + # 添加详细日志,记录最新消息信息 + if last_message: + logger.info(f"最新消息ID: {last_message.id}, 角色: {last_message.role}, 创建时间: {last_message.created_at}") + + # 检查更详细的元数据 + if hasattr(last_message, 'metadata') and last_message.metadata: + logger.info(f"最新消息元数据: {last_message.metadata}") + + # 检查最新消息是否是今天的 + from django.utils import timezone + today = timezone.now().date() + msg_date = last_message.created_at.date() + is_today = (msg_date == today) + logger.info(f"最新消息日期: {msg_date}, 是否是今天: {is_today}") + else: + logger.info("没有找到任何消息记录") + + # 先尝试刷新Gmail邮件,确保获取最新消息 + try: + # 获取最近的几封邮件并处理 + recent_emails = gmail_integration.get_recent_emails(from_email=talent_email, max_results=3) + if recent_emails: + logger.info(f"尝试处理最近的 {len(recent_emails)} 封来自 {talent_email} 的邮件") + result = gmail_integration.save_conversations_to_knowledge_base(recent_emails, kb) + logger.info(f"刷新处理结果: {result}") + + # 重新获取对话历史,确保包含最新消息 + chat_messages = ChatHistory.objects.filter( + user=user, + knowledge_base=kb, + conversation_id=conversation_id, + is_deleted=False + ).order_by('created_at') + + # 更新最新消息 + last_message = chat_messages.last() + if last_message: + logger.info(f"刷新后的最新消息ID: {last_message.id}, 角色: {last_message.role}, 创建时间: {last_message.created_at}") + except Exception as refresh_error: + logger.error(f"尝试刷新最新邮件失败: {str(refresh_error)}") + logger.error(traceback.format_exc()) + + # 根据消息的role判断是否是达人回复 + # 在系统中,达人的回复角色为'assistant',系统自动生成的回复角色为'user' + has_talent_reply = False + + if last_message and last_message.role == 'assistant': + # 检查这条消息是否已经处理过(是否已经针对此消息生成了回复) + has_response = ChatHistory.objects.filter( + user=user, + knowledge_base=kb, + conversation_id=conversation_id, + parent_id=str(last_message.id), + role='user', # 系统AI自动回复角色是user + is_deleted=False + ).exists() + + if has_response: + logger.info(f"最新达人消息 {last_message.id} 已经有回复,不再生成新回复") + has_talent_reply = False + else: + logger.info(f"发现尚未回复的达人消息: {last_message.id}") + has_talent_reply = True + + # 检查是否有新的达人回复 + if has_talent_reply: + # 有达人回复,生成AI回复 ai_response = generate_ai_response(conversation_history, user_goal) if not ai_response: @@ -749,26 +900,34 @@ def auto_chat_session(user, talent_email, max_turns=10): user=user, knowledge_base=kb, conversation_id=conversation_id, - role='assistant', - content=ai_response + role='user', # 修正:系统AI自动回复角色是user + content=ai_response, + parent_id=str(last_message.id) # 明确设置父消息ID为达人消息ID ) + logger.info(f"已保存AI回复: ID={ai_msg.id}, 父消息ID={last_message.id}") + # 使用最近的主题作为回复主题 - subject = "回复: " + (last_message.subject if hasattr(last_message, 'subject') and last_message.subject else "关于合作的洽谈") + subject = "回复: " + (last_message.metadata.get('subject') if hasattr(last_message, 'metadata') and last_message.metadata and 'subject' in last_message.metadata else "关于合作的洽谈") # 实际发送邮件 - email_result = gmail_integration.send_email( - to_email=talent_email, - subject=subject, - body=ai_response, - conversation_id=conversation_id - ) - - if email_result['status'] != 'success': - logger.error(f"发送邮件回复失败: {email_result.get('message', 'Unknown error')}") + try: + email_result = gmail_integration.send_email( + to_email=talent_email, + subject=subject, + body=ai_response, + conversation_id=conversation_id + ) + + gmail_account = gmail_credential.gmail_email if gmail_credential and gmail_credential.gmail_email else "未设置" + logger.info(f"已发送邮件回复到 {talent_email},使用Gmail账号: {gmail_account},消息ID: {email_result}") + + except Exception as e: + logger.error(f"发送邮件回复失败: {str(e)}") + logger.error(traceback.format_exc()) return { 'status': 'error', - 'message': f"发送邮件回复失败: {email_result.get('message', 'Unknown error')}" + 'message': f"发送邮件回复失败: {str(e)}" } # 如果目标已达成,发送通知 @@ -789,15 +948,27 @@ def auto_chat_session(user, talent_email, max_turns=10): 'conversation_id': conversation_id } else: - # 没有新的达人回复,不需要回复 - return { - 'status': 'success', - 'message': '没有新的达人回复,不需要回复', - 'turns_completed': 0, - 'goal_achieved': False, - 'email_sent': False, - 'conversation_id': conversation_id - } + # 没有新的达人回复,确保Gmail监听已设置 + try: + # 检查并更新Gmail监听 + gmail_integration.setup_watch() + gmail_account = gmail_credential.gmail_email if gmail_credential and gmail_credential.gmail_email else "未设置" + logger.info(f"已更新Gmail账号 {gmail_account} 的监听") + + return { + 'status': 'success', + 'message': '没有新的达人回复,已更新Gmail监听,将继续等待回复', + 'turns_completed': 0, + 'goal_achieved': False, + 'email_sent': False, + 'conversation_id': conversation_id + } + except Exception as e: + logger.error(f"更新Gmail监听失败: {str(e)}") + return { + 'status': 'error', + 'message': f"更新Gmail监听失败: {str(e)}" + } except Exception as e: logger.error(f"自动聊天会话出错: {str(e)}") @@ -807,35 +978,6 @@ def auto_chat_session(user, talent_email, max_turns=10): 'message': str(e) } -def simulate_user_reply(ai_response, turn_count): - """ - 模拟用户回复 - - 参数: - ai_response: AI的回复内容 - turn_count: 当前对话轮次 - - 返回: - str: 模拟的用户回复 - """ - # 根据对话轮次和AI回复生成不同的回复模板 - if turn_count == 0: - return "你好,很高兴认识你。我对你提到的合作很感兴趣,能详细说说你们公司的情况吗?" - - if "价格" in ai_response or "报价" in ai_response: - return "价格是我比较关注的,你们能提供什么样的价格方案?我希望能有一个灵活的合作模式。" - - if "合作模式" in ai_response or "合作方式" in ai_response: - return "这种合作模式听起来不错。我目前的粉丝群体主要是25-35岁的女性,她们对美妆和生活方式类产品比较感兴趣。你觉得这和你们的产品匹配吗?" - - if "产品" in ai_response: - return "产品听起来很不错。我想了解一下发货和售后是如何处理的?这对我的粉丝体验很重要。" - - if "合同" in ai_response or "协议" in ai_response: - return "我对合同条款没有太大问题,但希望能保持一定的创作自由度。什么时候可以开始合作?" - - # 默认回复 - return "这些信息很有帮助,谢谢。我需要再考虑一下,有什么其他你想告诉我的吗?" def send_goal_achieved_notification(user, talent_email, conversation_id): """ @@ -865,6 +1007,394 @@ def send_goal_achieved_notification(user, talent_email, conversation_id): logger.error(f"发送目标达成通知失败: {str(e)}") logger.error(traceback.format_exc()) +def export_matching_emails_to_excel(matching_emails, records, output_path=None): + """ + 将匹配到的邮箱数据导出到Excel文件 + + 参数: + matching_emails: 匹配到的邮箱字典 + records: 原始飞书记录 + output_path: 输出文件路径,默认为当前目录下的'matching_emails_{timestamp}.xlsx' + + 返回: + str: 导出文件的路径 + """ + try: + # 如果未指定输出路径,创建默认路径 + if not output_path: + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + output_path = f'matching_emails_{timestamp}.xlsx' + + # 创建数据列表 + data = [] + + # 遍历匹配的邮箱记录 + for email, email_records in matching_emails.items(): + for record in email_records: + fields = record.fields + row = { + 'Email': email, + 'RecordID': record.record_id + } + + # 提取所有字段 + for field_name, field_value in fields.items(): + row[field_name] = extract_field_value(field_value) + + data.append(row) + + # 如果没有数据,返回错误 + if not data: + logger.error("没有匹配的邮箱数据可以导出") + return None + + # 创建DataFrame并导出到Excel + df = pd.DataFrame(data) + df.to_excel(output_path, index=False, engine='openpyxl') + + logger.info(f"成功导出匹配的邮箱数据到: {output_path}") + return output_path + + except Exception as e: + logger.error(f"导出匹配邮箱数据到Excel时出错: {str(e)}") + logger.error(traceback.format_exc()) + return None + +def export_feishu_creators_to_excel(matching_emails, output_path=None): + """ + 将匹配到的FeishuCreator数据导出到Excel文件 + + 参数: + matching_emails: 匹配到的邮箱字典 + output_path: 输出文件路径,默认为当前目录下的'feishu_creators_{timestamp}.xlsx' + + 返回: + str: 导出文件的路径 + """ + try: + # 如果未指定输出路径,创建默认路径 + if not output_path: + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + output_path = f'feishu_creators_{timestamp}.xlsx' + + # 创建数据列表 + data = [] + + # 获取所有匹配的邮箱 + emails = list(matching_emails.keys()) + + # 从数据库获取FeishuCreator记录 + creators = FeishuCreator.objects.filter(email__in=emails) + + if not creators.exists(): + logger.error("没有找到匹配的FeishuCreator记录") + return None + + # 遍历Creator记录并添加到数据列表 + for creator in creators: + # 处理datetime字段,移除时区信息 + created_at = creator.created_at + if hasattr(created_at, 'tzinfo') and created_at.tzinfo is not None: + created_at = created_at.replace(tzinfo=None) + + updated_at = creator.updated_at + if hasattr(updated_at, 'tzinfo') and updated_at.tzinfo is not None: + updated_at = updated_at.replace(tzinfo=None) + + row = { + 'id': str(creator.id), # 转换UUID为字符串 + 'handle': creator.handle, # 使用handle代替name + 'email': creator.email, + 'phone': creator.phone, # 使用phone代替phone_number + 'social_platform': 'TikTok', # 假设平台为TikTok + 'fans_count': creator.fans_count, # 粉丝数 + 'account_type': creator.account_type, # 账号属性 + 'region': creator.creator_base, # 使用creator_base作为region + 'cooperation_intention': creator.cooperation_intention, # 合作意向 + 'created_at': created_at, + 'updated_at': updated_at, + 'is_deleted': False, # FeishuCreator没有is_deleted字段,默认为False + 'remarks': creator.notes, # 使用notes作为remarks + 'status': creator.contact_status # 使用contact_status作为status + } + + # 添加其他可能有用的字段 + row['price_quote'] = creator.price_quote + row['payment_method'] = creator.payment_method + row['contact_person'] = creator.contact_person + row['tiktok_url'] = creator.tiktok_url + row['gmv'] = creator.gmv + row['has_ooin'] = creator.has_ooin + row['address'] = creator.address + + data.append(row) + + # 创建DataFrame并导出到Excel + df = pd.DataFrame(data) + + # 尝试使用不同的Excel引擎 + try: + # 首先尝试使用openpyxl + df.to_excel(output_path, index=False, engine='openpyxl') + except (ImportError, ModuleNotFoundError): + try: + # 如果openpyxl不可用,尝试使用xlsxwriter + df.to_excel(output_path, index=False, engine='xlsxwriter') + except (ImportError, ModuleNotFoundError): + # 如果两者都不可用,使用默认引擎 + df.to_excel(output_path, index=False) + + logger.info(f"成功导出FeishuCreator数据到: {output_path}") + return output_path + + except Exception as e: + logger.error(f"导出FeishuCreator数据到Excel时出错: {str(e)}") + logger.error(traceback.format_exc()) + return None + +def process_gmail_notification(notification_data, user=None): + """ + 处理Gmail推送通知,在收到达人回复时自动生成AI回复 + + 参数: + notification_data: Gmail推送通知数据 + user: 用户对象,如果为None则使用通知中的邮箱查找用户 + + 返回: + dict: 处理结果 + """ + try: + # 导入所需模型 + from user_management.models import User, GmailCredential, GmailTalentMapping, ChatHistory + from user_management.gmail_integration import GmailIntegration + + # 1. 从通知数据中提取信息 + # 处理Google Pub/Sub消息格式 + if isinstance(notification_data, dict) and 'message' in notification_data and 'data' in notification_data['message']: + try: + import base64 + import json + logger.info("检测到Google Pub/Sub消息格式") + + # Base64解码data字段 + encoded_data = notification_data['message']['data'] + decoded_data = base64.b64decode(encoded_data).decode('utf-8') + logger.info(f"解码后的数据: {decoded_data}") + + # 解析JSON获取email和historyId + json_data = json.loads(decoded_data) + email = json_data.get('emailAddress') + history_id = json_data.get('historyId') + logger.info(f"从Pub/Sub消息中提取: email={email}, historyId={history_id}") + except Exception as decode_error: + logger.error(f"解析Pub/Sub消息失败: {str(decode_error)}") + logger.error(traceback.format_exc()) + return {'status': 'error', 'message': f"解析通知失败: {str(decode_error)}"} + else: + # 原始格式处理 + email = notification_data.get('emailAddress') + history_id = notification_data.get('historyId') + + if not email or not history_id: + logger.error("Gmail通知数据不完整: 找不到emailAddress或historyId") + return {'status': 'error', 'message': "通知数据不完整"} + + # 2. 查找关联用户 + if not user: + user = User.objects.filter(email=email).first() + + # 如果找不到用户,尝试使用gmail_email字段查找 + if not user: + logger.info(f"找不到email={email}的用户,尝试使用gmail_email查找") + credential = GmailCredential.objects.filter(gmail_email=email, is_active=True).first() + if credential: + user = credential.user + logger.info(f"通过gmail_email找到用户: {user.email}") + + if not user: + logger.error(f"找不到与邮箱 {email} 关联的用户") + return {'status': 'error', 'message': f"找不到与邮箱 {email} 关联的用户"} + + # 3. 初始化Gmail集成 + gmail_integration = GmailIntegration(user) + + # 4. 获取历史记录变更 + message_ids = gmail_integration.get_history(history_id) + if not message_ids: + logger.info(f"没有新消息: historyId={history_id}") + return {'status': 'success', 'message': "没有新消息", 'processed': 0} + + # 5. 处理新消息 + processed_count = 0 + for message_id in message_ids: + # 获取邮件详情 + message = gmail_integration.gmail_service.users().messages().get( + userId='me', id=message_id + ).execute() + + # 提取邮件内容 + email_data = gmail_integration._extract_email_content(message) + if not email_data: + logger.error(f"提取邮件内容失败: {message_id}") + continue + + # 获取发件人邮箱 + from_email = email_data.get('from', '') + sender_email = '' + if '<' in from_email and '>' in from_email: + # 格式如 "姓名 " + sender_email = from_email.split('<')[1].split('>')[0] + else: + # 格式可能直接是邮箱 + sender_email = from_email + + # 检查是否是用户自己发送的邮件 + if sender_email.lower() == user.email.lower(): + logger.info(f"跳过用户自己发送的邮件: {message_id}") + continue + + # 查找与发件人关联的Gmail达人映射 + talent_mapping = GmailTalentMapping.objects.filter( + user=user, + talent_email=sender_email, + is_active=True + ).first() + + if not talent_mapping: + logger.info(f"找不到与发件人 {sender_email} 的映射关系,跳过处理") + continue + + # 获取知识库和对话ID + kb = talent_mapping.knowledge_base + conversation_id = talent_mapping.conversation_id + + # 检查消息是否已经处理过 + if ChatHistory.objects.filter( + conversation_id=conversation_id, + metadata__gmail_message_id=message_id, + is_deleted=False + ).exists(): + logger.info(f"邮件已处理过,跳过: {message_id}") + continue + + # 记录达人回复到对话历史 + metadata = { + 'gmail_message_id': message_id, + 'from': from_email, + 'date': email_data.get('date', ''), + 'subject': email_data.get('subject', '') + } + + # 保存达人回复到对话历史,角色为assistant + chat_message = ChatHistory.objects.create( + user=user, + knowledge_base=kb, + conversation_id=conversation_id, + role='assistant', # 达人回复角色为assistant + content=f"[{email_data['subject']}] {email_data['body']}", + metadata=metadata + ) + + # 获取对话历史 + chat_messages = ChatHistory.objects.filter( + user=user, + knowledge_base=kb, + conversation_id=conversation_id, + is_deleted=False + ).order_by('created_at') + + # 检查消息是否已经处理过并有回复 + has_response = ChatHistory.objects.filter( + user=user, + knowledge_base=kb, + conversation_id=conversation_id, + parent_id=str(chat_message.id), + role='user', # 系统回复角色是user + is_deleted=False + ).exists() + + if has_response: + logger.info(f"达人消息 {message_id} 已有回复,跳过自动回复生成") + continue + + conversation_history = [] + for msg in chat_messages: + conversation_history.append({ + "role": msg.role, + "content": msg.content + }) + + # 获取用户目标 + goal_result = gmail_integration.manage_user_goal() + if goal_result['status'] != 'success' or not goal_result.get('goal'): + logger.error(f"获取用户目标失败: {goal_result.get('message', 'No goal found')}") + continue + + user_goal = goal_result['goal']['content'] + + # 生成AI回复 + ai_response = generate_ai_response(conversation_history, user_goal) + + if not ai_response: + logger.error("生成AI回复失败") + continue + + # 检查目标是否已达成 + goal_achieved = check_goal_achieved(ai_response) + + # 保存AI回复到对话历史 + ai_msg = ChatHistory.objects.create( + user=user, + knowledge_base=kb, + conversation_id=conversation_id, + role='user', # 系统AI回复角色为user + content=ai_response + ) + + # 使用最近的主题作为回复主题 + subject = "回复: " + (email_data.get('subject', '关于合作的洽谈')) + + # 发送AI回复邮件 + try: + email_result = gmail_integration.send_email( + to_email=sender_email, + subject=subject, + body=ai_response, + conversation_id=conversation_id + ) + + logger.info(f"已发送邮件回复到 {sender_email},消息ID: {email_result}") + processed_count += 1 + + # 如果目标已达成,发送通知 + if goal_achieved: + send_goal_achieved_notification(user, sender_email, conversation_id) + # 生成对话总结 + summary_result = gmail_integration.generate_conversation_summary(sender_email) + logger.info(f"目标已达成,生成对话总结: {summary_result.get('status', 'failed')}") + except Exception as e: + logger.error(f"发送邮件回复失败: {str(e)}") + logger.error(traceback.format_exc()) + return { + 'status': 'error', + 'message': f"发送邮件回复失败: {str(e)}" + } + + # 返回处理结果 + return { + 'status': 'success', + 'message': f"成功处理 {processed_count} 条新消息", + 'processed': processed_count + } + + except Exception as e: + logger.error(f"处理Gmail通知失败: {str(e)}") + logger.error(traceback.format_exc()) + return { + 'status': 'error', + 'message': str(e) + } + def print_help(): """打印帮助信息""" print("飞书多维表格自动AI对话工具") @@ -872,6 +1402,10 @@ def print_help(): print("\n可用命令:") print(" 1. 从飞书读取表格并处理重复邮箱:") print(" python feishu_ai_chat.py process_table --app_token 应用TOKEN --table_id 表格ID --view_id 视图ID --access_token 访问令牌") + print(" 选项:") + print(" --export_excel 导出匹配的邮箱数据到Excel") + print(" --export_creators 导出匹配的FeishuCreator数据到Excel") + print(" --output_path 文件路径 指定Excel导出文件路径") print() print(" 2. 为指定邮箱执行自动对话:") print(" python feishu_ai_chat.py auto_chat --email 达人邮箱 [--turns 对话轮数]") @@ -882,12 +1416,26 @@ def print_help(): print(" 4. 检查目标完成状态:") print(" python feishu_ai_chat.py check_goal --email 达人邮箱") print() - print(" 5. 帮助信息:") + print(" 5. 导出匹配的邮箱数据到Excel:") + print(" python feishu_ai_chat.py export_excel --app_token 应用TOKEN --table_id 表格ID --view_id 视图ID [--output_path 文件路径]") + print() + print(" 6. 导出匹配的FeishuCreator数据到Excel:") + print(" python feishu_ai_chat.py export_creators --app_token 应用TOKEN --table_id 表格ID --view_id 视图ID [--output_path 文件路径]") + print() + print(" 7. 处理Gmail通知:") + print(" python feishu_ai_chat.py process_notification --data 通知数据JSON或文件路径 [--user_email 用户邮箱]") + print() + print(" 8. 导出所有达人回复数据到Excel:") + print(" python feishu_ai_chat.py export_talent_replies [--user_email 用户邮箱] [--output_path 文件路径]") + print() + print(" 9. 帮助信息:") print(" python feishu_ai_chat.py help") print() print("示例:") - print(" python feishu_ai_chat.py process_table --table_id tblcck2za8GZBliz --view_id vewSOIsmxc") + print(" python feishu_ai_chat.py process_table --table_id tblcck2za8GZBliz --view_id vewSOIsmxc --export_excel") + print(" python feishu_ai_chat.py export_creators --table_id tblcck2za8GZBliz --view_id vewSOIsmxc") print(" python feishu_ai_chat.py auto_chat --email example@gmail.com --turns 5") + print(" python feishu_ai_chat.py export_talent_replies --user_email admin@example.com") print() def handle_process_table(args): @@ -902,7 +1450,11 @@ def handle_process_table(args): parser.add_argument('--app_secret', default="fdVeOCLXmuIHZVmSV0VbJh9wd0Kq1o5y", help='应用密钥') parser.add_argument('--goal_template', default="与达人{handle}(邮箱:{email})建立联系并了解其账号情况,评估合作潜力,处理合作需求,最终目标是达成合作并签约。", help='目标内容模板') parser.add_argument('--auto_chat', action='store_true', help='是否自动执行AI对话') + parser.add_argument('--auto_reply', action='store_true', help='是否自动设置Gmail回复') parser.add_argument('--turns', type=int, default=5, help='自动对话轮次') + parser.add_argument('--export_excel', action='store_true', help='是否导出匹配的邮箱数据到Excel') + parser.add_argument('--export_creators', action='store_true', help='是否导出匹配的FeishuCreator数据到Excel') + parser.add_argument('--output_path', help='Excel导出文件路径') params = parser.parse_args(args) @@ -924,17 +1476,37 @@ def handle_process_table(args): duplicate_emails = find_duplicate_email_creators(records) if not duplicate_emails: - logger.info("未发现重复邮箱") + logger.info("未找到与系统中已有creator匹配的邮箱") return - logger.info(f"发现 {len(duplicate_emails)} 个重复邮箱,开始处理...") + logger.info(f"发现 {len(duplicate_emails)} 个匹配的邮箱,开始处理...") # 处理重复邮箱记录 - results = process_duplicate_emails(duplicate_emails, params.goal_template) + results = process_duplicate_emails(duplicate_emails, params.goal_template, params.auto_reply) # 打印处理结果 logger.info(f"处理完成: 总计 {results['total']} 个邮箱,成功 {results['success']} 个,失败 {results['failure']} 个") + # 如果启用了自动回复,显示回复设置结果 + if params.auto_reply: + logger.info(f"自动回复设置: 成功 {results.get('auto_reply_setup', 0)} 个") + + # 如果需要导出邮箱Excel + if params.export_excel: + output_path = export_matching_emails_to_excel(duplicate_emails, records, params.output_path) + if output_path: + logger.info(f"已将匹配的邮箱数据导出到: {output_path}") + else: + logger.error("导出邮箱Excel失败") + + # 如果需要导出FeishuCreator Excel + if params.export_creators: + output_path = export_feishu_creators_to_excel(duplicate_emails, params.output_path) + if output_path: + logger.info(f"已将匹配的FeishuCreator数据导出到: {output_path}") + else: + logger.error("导出FeishuCreator Excel失败") + # 如果需要自动对话 if params.auto_chat: # 获取组长用户 @@ -1162,6 +1734,142 @@ def handle_check_goal(args): return result +def handle_export_excel(args): + """处理导出Excel命令""" + import argparse + parser = argparse.ArgumentParser(description='导出匹配的邮箱数据到Excel') + parser.add_argument('--app_token', default="XYE6bMQUOaZ5y5svj4vcWohGnmg", help='飞书应用TOKEN') + parser.add_argument('--table_id', required=True, help='表格ID') + parser.add_argument('--view_id', required=True, help='视图ID') + parser.add_argument('--access_token', default=None, help='用户访问令牌') + parser.add_argument('--app_id', default="cli_a5c97daacb9e500d", help='应用ID') + parser.add_argument('--app_secret', default="fdVeOCLXmuIHZVmSV0VbJh9wd0Kq1o5y", help='应用密钥') + parser.add_argument('--output_path', help='Excel导出文件路径') + + params = parser.parse_args(args) + + # 从飞书表格获取记录 + records = fetch_table_records( + params.app_token, + params.table_id, + params.view_id, + params.access_token, + params.app_id, + params.app_secret + ) + + if not records: + logger.error("未获取到任何记录") + return + + # 查找重复邮箱的创作者 + duplicate_emails = find_duplicate_email_creators(records) + + if not duplicate_emails: + logger.info("未找到与系统中已有creator匹配的邮箱") + return + + logger.info(f"发现 {len(duplicate_emails)} 个匹配的邮箱,开始导出...") + + # 导出到Excel + output_path = export_matching_emails_to_excel(duplicate_emails, records, params.output_path) + + if output_path: + logger.info(f"已将匹配的邮箱数据导出到: {output_path}") + else: + logger.error("导出Excel失败") + +def handle_export_creators(args): + """处理导出FeishuCreator到Excel命令""" + import argparse + parser = argparse.ArgumentParser(description='导出匹配的FeishuCreator数据到Excel') + parser.add_argument('--app_token', default="XYE6bMQUOaZ5y5svj4vcWohGnmg", help='飞书应用TOKEN') + parser.add_argument('--table_id', required=True, help='表格ID') + parser.add_argument('--view_id', required=True, help='视图ID') + parser.add_argument('--access_token', default=None, help='用户访问令牌') + parser.add_argument('--app_id', default="cli_a5c97daacb9e500d", help='应用ID') + parser.add_argument('--app_secret', default="fdVeOCLXmuIHZVmSV0VbJh9wd0Kq1o5y", help='应用密钥') + parser.add_argument('--output_path', help='Excel导出文件路径') + + params = parser.parse_args(args) + + # 从飞书表格获取记录 + records = fetch_table_records( + params.app_token, + params.table_id, + params.view_id, + params.access_token, + params.app_id, + params.app_secret + ) + + if not records: + logger.error("未获取到任何记录") + return + + # 查找重复邮箱的创作者 + duplicate_emails = find_duplicate_email_creators(records) + + if not duplicate_emails: + logger.info("未找到与系统中已有creator匹配的邮箱") + return + + logger.info(f"发现 {len(duplicate_emails)} 个匹配的邮箱,开始导出FeishuCreator数据...") + + # 导出到Excel + output_path = export_feishu_creators_to_excel(duplicate_emails, params.output_path) + + if output_path: + logger.info(f"已将匹配的FeishuCreator数据导出到: {output_path}") + else: + logger.error("导出FeishuCreator Excel失败") + +def handle_process_notification(args): + """处理Gmail通知命令""" + import argparse + import json + parser = argparse.ArgumentParser(description='处理Gmail通知') + parser.add_argument('--data', required=True, help='通知数据JSON字符串或文件路径') + parser.add_argument('--user_email', help='用户邮箱') + + params = parser.parse_args(args) + + # 获取通知数据 + notification_data = None + try: + # 尝试解析为JSON字符串 + notification_data = json.loads(params.data) + except json.JSONDecodeError: + # 可能是文件路径 + try: + with open(params.data, 'r') as f: + notification_data = json.load(f) + except Exception as e: + logger.error(f"读取通知数据失败: {str(e)}") + return + + if not notification_data: + logger.error("无法解析通知数据") + return + + # 如果指定了用户邮箱,查找用户 + user = None + if params.user_email: + from user_management.models import User + user = User.objects.filter(email=params.user_email).first() + if not user: + logger.error(f"找不到邮箱为 {params.user_email} 的用户") + return + + # 处理通知 + result = process_gmail_notification(notification_data, user) + + # 打印结果 + if result['status'] == 'success': + logger.info(f"处理通知成功: {result['message']}") + else: + logger.error(f"处理通知失败: {result['message']}") + def main(): """命令行入口函数""" import sys @@ -1181,9 +1889,513 @@ def main(): handle_set_goal(args) elif command == 'check_goal': handle_check_goal(args) + elif command == 'export_excel': + handle_export_excel(args) + elif command == 'export_creators': + handle_export_creators(args) + elif command == 'process_notification': + handle_process_notification(args) + elif command == 'export_talent_replies': + handle_export_talent_replies(args) else: print(f"未知命令: {command}") print_help() if __name__ == "__main__": - main() \ No newline at end of file + main() + +# Django API视图函数 +def api_process_table(request): + """ + Django API视图: 处理飞书表格并设置Gmail自动回复 + + URL: /api/feishu/process-table/ + 方法: POST + + 请求参数: + { + "app_token": "飞书应用TOKEN", + "table_id": "表格ID", + "view_id": "视图ID", + "access_token": "访问令牌(可选)", + "app_id": "应用ID(可选)", + "app_secret": "应用密钥(可选)", + "goal_template": "目标内容模板(可选)", + "auto_reply": true, // 是否自动设置Gmail回复 + "export_excel": false, // 是否导出匹配的邮箱数据到Excel + "export_creators": false // 是否导出匹配的FeishuCreator数据到Excel + } + + 响应: + { + "status": "success", + "message": "处理完成: 总计 X 个邮箱,成功 Y 个,失败 Z 个", + "total": 10, + "success": 8, + "failure": 2, + "auto_reply_setup": 8, // 如果启用了自动回复 + "details": [...] + } + """ + try: + from django.http import JsonResponse + import json + + # 1. 解析请求体 + try: + if request.method == 'POST': + if request.content_type == 'application/json': + request_data = json.loads(request.body) + else: + request_data = request.POST.dict() + else: + return JsonResponse({ + 'status': 'error', + 'message': f"不支持的请求方法: {request.method}" + }, status=405) + except json.JSONDecodeError: + return JsonResponse({ + 'status': 'error', + 'message': "无效的JSON格式" + }, status=400) + + # 2. 调用处理函数 + results = process_table_api(request_data) + + # 3. 返回结果 + status_code = 200 if results.get('status') == 'success' else 400 + return JsonResponse(results, status=status_code) + + except Exception as e: + logger.error(f"API处理飞书表格出错: {str(e)}") + logger.error(traceback.format_exc()) + return JsonResponse({ + 'status': 'error', + 'message': str(e) + }, status=500) + +def api_gmail_webhook(request): + """ + Django API视图: 处理Gmail推送通知 + + URL: /api/gmail/webhook/ + 方法: POST + + 请求参数: Google Pub/Sub推送格式 + { + "message": { + "data": "BASE64编码的推送数据", + "messageId": "消息ID", + "publishTime": "发布时间" + }, + "subscription": "订阅名称" + } + + 响应: + { + "status": "success", + "message": "成功处理 X 条新消息", + "processed": 1 + } + """ + try: + from django.http import JsonResponse + import json + + # 1. 解析请求体 + try: + if request.method == 'POST': + if request.content_type == 'application/json': + notification_data = json.loads(request.body) + else: + return JsonResponse({ + 'status': 'error', + 'message': "请求Content-Type必须为application/json" + }, status=400) + else: + return JsonResponse({ + 'status': 'error', + 'message': f"不支持的请求方法: {request.method}" + }, status=405) + except json.JSONDecodeError: + return JsonResponse({ + 'status': 'error', + 'message': "无效的JSON格式" + }, status=400) + + # 2. 调用处理函数 + result = process_gmail_notification(notification_data) + + # 3. 返回结果 + status_code = 200 if result.get('status') == 'success' else 400 + return JsonResponse(result, status=status_code) + + except Exception as e: + logger.error(f"API处理Gmail通知出错: {str(e)}") + logger.error(traceback.format_exc()) + return JsonResponse({ + 'status': 'error', + 'message': str(e) + }, status=500) + +def api_auto_chat(request): + """ + Django API视图: 初始化自动对话 + + URL: /api/auto_chat/ + 方法: POST + + 请求参数: + { + "talent_email": "达人邮箱", + "max_turns": 10 // 可选,最大对话轮次 + } + + 响应: + { + "status": "success", + "message": "已发送首次邮件并设置Gmail监听,等待达人回复", + "turns_completed": 1, + "goal_achieved": false, + "email_sent": true, + "conversation_id": "会话ID" + } + """ + try: + from django.http import JsonResponse + import json + from django.contrib.auth import get_user_model + + # 1. 解析请求体 + try: + if request.method == 'POST': + if request.content_type == 'application/json': + request_data = json.loads(request.body) + else: + request_data = request.POST.dict() + else: + return JsonResponse({ + 'status': 'error', + 'message': f"不支持的请求方法: {request.method}" + }, status=405) + except json.JSONDecodeError: + return JsonResponse({ + 'status': 'error', + 'message': "无效的JSON格式" + }, status=400) + + # 2. 获取必要参数 + talent_email = request_data.get('talent_email') + if not talent_email: + return JsonResponse({ + 'status': 'error', + 'message': "缺少必要参数: talent_email" + }, status=400) + + max_turns = request_data.get('max_turns', 10) + + # 3. 获取用户 - 优先使用当前登录用户,否则使用组长用户 + user = request.user if hasattr(request, 'user') and request.user.is_authenticated else None + + if not user: + User = get_user_model() + user = User.objects.filter(role='leader').first() + + if not user: + return JsonResponse({ + 'status': 'error', + 'message': "未找到可用用户,请先登录或确保系统中有组长用户" + }, status=400) + + # 4. 执行自动对话 + result = auto_chat_session(user, talent_email, max_turns=max_turns) + + # 5. 返回结果 + status_code = 200 if result.get('status') == 'success' else 400 + return JsonResponse(result, status=status_code) + + except Exception as e: + logger.error(f"API初始化自动对话出错: {str(e)}") + logger.error(traceback.format_exc()) + return JsonResponse({ + 'status': 'error', + 'message': str(e) + }, status=500) + +def export_talent_replies_to_excel(user=None, output_path=None): + """ + 导出达人回复数据到Excel文件 + + 参数: + user: 用户对象,如果为None则导出所有用户的数据 + output_path: 输出文件路径,默认为当前目录下的'talent_replies_{timestamp}.xlsx' + + 返回: + str: 导出文件的路径 + """ + try: + # 导入需要的模块 + import pandas as pd + from datetime import datetime + from django.db.models import Q, F + from user_management.models import ChatHistory, GmailTalentMapping, UserGoal, User + + # 如果未指定输出路径,创建默认路径 + if not output_path: + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + output_path = f'talent_replies_{timestamp}.xlsx' + + # 准备查询 - 查询达人发送的消息,role='assistant'表示达人回复 + query = Q(role='assistant') & Q(is_deleted=False) + + # 如果指定了用户,只查询该用户相关的数据 + if user: + query &= Q(user=user) + logger.info(f"导出用户 {user.email} 收到的所有达人回复数据") + else: + logger.info("导出所有用户收到的达人回复数据") + + # 获取所有达人回复消息 + talent_replies = ChatHistory.objects.filter(query).order_by('conversation_id', 'created_at') + + if not talent_replies.exists(): + logger.warning("没有找到任何达人回复记录") + return None + + # 创建数据列表 + data = [] + + # 获取所有相关的映射关系 + conversation_ids = talent_replies.values_list('conversation_id', flat=True).distinct() + talent_mappings = GmailTalentMapping.objects.filter(conversation_id__in=conversation_ids) + + # 创建映射字典,用于快速查找 + mapping_dict = {} + for mapping in talent_mappings: + mapping_dict[mapping.conversation_id] = mapping.talent_email + + # 获取所有用户的目标 + user_ids = talent_replies.values_list('user_id', flat=True).distinct() + goals = UserGoal.objects.filter(user_id__in=user_ids, is_active=True) + + # 创建目标字典,用于快速查找 + goal_dict = {} + for goal in goals: + goal_dict[goal.user_id] = goal.content + + # 获取所有相关用户 + users = User.objects.filter(id__in=user_ids) + user_dict = {} + for u in users: + user_dict[u.id] = u.email + + # 遍历达人回复 + for reply in talent_replies: + # 获取回复的父消息(可能是用户消息) + parent_message = None + if reply.parent_id: + try: + # 使用UUID查询方式,避免ID格式转换问题 + parent_message = ChatHistory.objects.filter(conversation_id=reply.conversation_id).filter(id=reply.parent_id).first() + # 如果没找到,可能是字符串ID + if not parent_message: + logger.warning(f"无法直接通过ID查找父消息, ID: {reply.parent_id}, 尝试其他方式查找...") + except Exception as e: + logger.error(f"查询父消息时出错: {str(e)}") + logger.error(traceback.format_exc()) + + # 从metadata获取额外信息 + metadata = reply.metadata or {} + + # 准备行数据 + row = { + 'ID': str(reply.id), + '用户邮箱': user_dict.get(reply.user_id, '未知'), + '达人邮箱': mapping_dict.get(reply.conversation_id, '未知'), + '对话ID': reply.conversation_id, + '内容': reply.content, + '创建时间': reply.created_at.strftime('%Y-%m-%d %H:%M:%S'), + '父消息ID': reply.parent_id, + '父消息内容': parent_message.content if parent_message else '', + '父消息时间': parent_message.created_at.strftime('%Y-%m-%d %H:%M:%S') if parent_message else '', + '用户总目标': goal_dict.get(reply.user_id, '未设置'), + '邮件主题': metadata.get('subject', ''), + '发件人': metadata.get('from', ''), + '邮件日期': metadata.get('date', ''), + 'Gmail消息ID': metadata.get('gmail_message_id', '') + } + + data.append(row) + + # 创建DataFrame + df = pd.DataFrame(data) + + # 导出到Excel + try: + # 首先尝试使用openpyxl + df.to_excel(output_path, index=False, engine='openpyxl') + except Exception as e: + logger.warning(f"使用openpyxl导出失败,尝试使用默认引擎: {e}") + # 如果openpyxl不可用,使用默认引擎 + df.to_excel(output_path, index=False) + + logger.info(f"成功导出 {len(data)} 条达人回复记录到: {output_path}") + return output_path + + except Exception as e: + logger.error(f"导出达人回复数据到Excel时出错: {str(e)}") + logger.error(traceback.format_exc()) + return None + +def handle_export_talent_replies(args): + """处理导出达人回复数据命令""" + import argparse + parser = argparse.ArgumentParser(description='导出达人回复数据到Excel') + parser.add_argument('--user_email', help='用户邮箱,不指定则导出所有用户数据') + parser.add_argument('--output_path', help='Excel导出文件路径') + + params = parser.parse_args(args) + + # 如果指定了用户邮箱,查找用户 + user = None + if params.user_email: + from django.contrib.auth import get_user_model + User = get_user_model() + user = User.objects.filter(email=params.user_email).first() + if not user: + logger.error(f"找不到邮箱为 {params.user_email} 的用户") + return + + # 导出数据 + output_path = export_talent_replies_to_excel(user, params.output_path) + + if output_path: + logger.info(f"已将达人回复数据导出到: {output_path}") + else: + logger.error("导出达人回复数据失败") + +def api_export_talent_replies(request): + """ + Django API视图: 导出达人回复数据到Excel + + URL: /api/users/talent-replies/export/ + 方法: GET, POST + + 请求参数: + { + "user_email": "可选,指定用户邮箱", + "output_path": "可选,指定输出文件路径" + } + + 响应: + { + "status": "success", + "message": "已导出达人回复数据", + "file_path": "导出文件路径", + "records_count": 123 + } + + 注册URL: + 在urls.py中添加: + from feishu.feishu_ai_chat import api_export_talent_replies + path('api/users/talent-replies/export/', api_export_talent_replies, name='export_talent_replies') + """ + try: + from django.http import JsonResponse, FileResponse + import json + import os + + # 解析请求参数 + if request.method == 'POST': + if request.content_type == 'application/json': + params = json.loads(request.body) + else: + params = request.POST.dict() + else: + params = request.GET.dict() + + # 获取用户邮箱参数 + user_email = params.get('user_email') + output_path = params.get('output_path') + + # 如果指定了用户邮箱,查找用户 + user = None + if user_email: + from django.contrib.auth import get_user_model + User = get_user_model() + user = User.objects.filter(email=user_email).first() + if not user: + return JsonResponse({ + 'status': 'error', + 'message': f"找不到邮箱为 {user_email} 的用户" + }, status=400) + + # 导出数据 + file_path = export_talent_replies_to_excel(user, output_path) + + if not file_path: + return JsonResponse({ + 'status': 'error', + 'message': "导出达人回复数据失败" + }, status=500) + + # 获取导出的记录数量 + try: + import pandas as pd + df = pd.read_excel(file_path) + records_count = len(df) + except Exception as e: + logger.error(f"读取导出文件失败: {str(e)}") + records_count = 0 + + # 如果客户端要求直接下载文件 + if params.get('download') == 'true' and os.path.exists(file_path): + response = FileResponse(open(file_path, 'rb')) + response['Content-Disposition'] = f'attachment; filename="{os.path.basename(file_path)}"' + return response + + # 计算一些统计数据 + stats = { + 'records_count': records_count, + 'talents_count': 0, + 'conversations_count': 0, + 'date_range': { + 'start': '', + 'end': '' + } + } + + try: + if records_count > 0: + # 统计达人数量 + stats['talents_count'] = df['达人邮箱'].nunique() + # 统计对话数量 + stats['conversations_count'] = df['对话ID'].nunique() + # 统计日期范围 + if '创建时间' in df.columns: + df['创建时间'] = pd.to_datetime(df['创建时间']) + min_date = df['创建时间'].min() + max_date = df['创建时间'].max() + if not pd.isna(min_date) and not pd.isna(max_date): + stats['date_range']['start'] = min_date.strftime('%Y-%m-%d') + stats['date_range']['end'] = max_date.strftime('%Y-%m-%d') + except Exception as stats_error: + logger.error(f"计算统计数据失败: {stats_error}") + + # 否则返回文件信息 + return JsonResponse({ + 'status': 'success', + 'message': "已导出达人回复数据", + 'file_path': file_path, + 'records_count': records_count, + 'stats': stats, + 'download_url': f"/api/users/talent-replies/download/?path={file_path}" + }) + + except Exception as e: + logger.error(f"API导出达人回复数据出错: {str(e)}") + logger.error(traceback.format_exc()) + return JsonResponse({ + 'status': 'error', + 'message': str(e) + }, status=500) \ No newline at end of file diff --git a/fix_gmail_cred.py b/fix_gmail_cred.py new file mode 100644 index 0000000..1bc43d9 --- /dev/null +++ b/fix_gmail_cred.py @@ -0,0 +1,50 @@ +import os +import django + +# 设置Django环境 +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'role_based_system.settings') +django.setup() + +from user_management.models import GmailCredential + +# 清除特定凭证 +def fix_specific_credential(credential_id): + try: + cred = GmailCredential.objects.get(id=credential_id) + cred.credentials = None + cred.needs_reauth = True + cred.save() + print(f'已清除ID为{cred.id}的凭证,邮箱: {cred.gmail_email},并标记为需要重新授权') + return True + except GmailCredential.DoesNotExist: + print(f'找不到ID为{credential_id}的凭证') + return False + except Exception as e: + print(f'处理凭证{credential_id}时出错: {str(e)}') + return False + +# 清除所有可能已损坏的凭证 +def fix_all_credentials(): + credentials = GmailCredential.objects.all() + fixed_count = 0 + + for cred in credentials: + if cred.credentials: # 只处理有凭证数据的记录 + try: + # 直接清除凭证并标记需要重新认证 + cred.credentials = None + cred.needs_reauth = True + cred.save() + print(f'已清除ID为{cred.id}的凭证,邮箱: {cred.gmail_email}') + fixed_count += 1 + except Exception as e: + print(f'处理凭证{cred.id}时出错: {str(e)}') + + print(f'共清除了{fixed_count}个凭证') + +if __name__ == '__main__': + # 修复特定的凭证 + fix_specific_credential('936c37a7-c6e5-454a-aaef-7c66b1230507') + + # 或者修复所有凭证 + # fix_all_credentials() \ No newline at end of file diff --git a/gmail_tokens/gmail_token_9240c799-d5ea-4750-8c29-25770e9c7eeb.json b/gmail_tokens/gmail_token_9240c799-d5ea-4750-8c29-25770e9c7eeb.json new file mode 100644 index 0000000..d3ef883 --- /dev/null +++ b/gmail_tokens/gmail_token_9240c799-d5ea-4750-8c29-25770e9c7eeb.json @@ -0,0 +1 @@ +{"access_token": "ya29.a0AZYkNZgeGgZi3_OfGNwLC4ykCFeSfLHoth9GCDj_UJI9SvhY7nAuWl7Gf2qZH-4iUSeDX-fD6JWSAGPwK9rbNMCgX5RUbsExLA69L3XZ1Og5vE5U1HHePCdGSQ-Ceqxauob1tte2nQHdEaPaW5t9OLOQhwGXsCmGq2fhbNpgRgaCgYKAQ8SARYSFQHGX2Mi-I-L5Q--8gfUvX6_0juX1Q0177", "client_id": "266164728215-v84lngbp3vgr4ulql01sqkg5vaigf4a5.apps.googleusercontent.com", "client_secret": "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", "refresh_token": "1//0ed76IPW5-HYwCgYIARAAGA4SNwF-L9IrWvNjsRtcbhlXa0eSfKZsf1-pv5yWL6GSE-4ve6fPQNfIlP8ujruo9Y9B-eIZP6QjolY", "token_expiry": "2025-04-28T10:26:07Z", "token_uri": "https://oauth2.googleapis.com/token", "user_agent": null, "revoke_uri": "https://oauth2.googleapis.com/revoke", "id_token": null, "id_token_jwt": null, "token_response": {"access_token": "ya29.a0AZYkNZgeGgZi3_OfGNwLC4ykCFeSfLHoth9GCDj_UJI9SvhY7nAuWl7Gf2qZH-4iUSeDX-fD6JWSAGPwK9rbNMCgX5RUbsExLA69L3XZ1Og5vE5U1HHePCdGSQ-Ceqxauob1tte2nQHdEaPaW5t9OLOQhwGXsCmGq2fhbNpgRgaCgYKAQ8SARYSFQHGX2Mi-I-L5Q--8gfUvX6_0juX1Q0177", "expires_in": 3599, "scope": "https://mail.google.com/", "token_type": "Bearer"}, "scopes": ["https://mail.google.com/"], "token_info_uri": "https://oauth2.googleapis.com/tokeninfo", "invalid": false, "_class": "OAuth2Credentials", "_module": "oauth2client.client"} \ No newline at end of file diff --git a/gmail_tokens/gmail_token_b7bb432a-7620-4363-ac4a-3e29629a907b.json b/gmail_tokens/gmail_token_b7bb432a-7620-4363-ac4a-3e29629a907b.json new file mode 100644 index 0000000..ce5511a --- /dev/null +++ b/gmail_tokens/gmail_token_b7bb432a-7620-4363-ac4a-3e29629a907b.json @@ -0,0 +1 @@ +{"access_token": "ya29.a0AZYkNZi4RkJ4Lnod5-jpByMzYlS1F6qxcZ0JGEYFBO5k0RNt0AYNdXfd3_ykDpqcRDfJln6S8aSbhfqrLin_VeBJABuzXVbSz7MO1akOMa1IhW9yhtl02xGjag9pd1hreYEM9WztRXrwrzrM8dPEGO4oHJLlEQvgq5xoxO8BaCgYKAX8SARYSFQHGX2MiQLh3UOHBxP2eu5fLcEL5Fg0175", "client_id": "266164728215-v84lngbp3vgr4ulql01sqkg5vaigf4a5.apps.googleusercontent.com", "client_secret": "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", "refresh_token": "1//0efbA-jcfi1XZCgYIARAAGA4SNwF-L9IryppvK1Md7niUUz1nkTCkp8Skh6pVlL1PQe6eWTS8hL7Qorzw7UbmVx0sg9zgXs28pVo", "token_expiry": "2025-04-19T03:29:54Z", "token_uri": "https://oauth2.googleapis.com/token", "user_agent": null, "revoke_uri": "https://oauth2.googleapis.com/revoke", "id_token": null, "id_token_jwt": null, "token_response": {"access_token": "ya29.a0AZYkNZi4RkJ4Lnod5-jpByMzYlS1F6qxcZ0JGEYFBO5k0RNt0AYNdXfd3_ykDpqcRDfJln6S8aSbhfqrLin_VeBJABuzXVbSz7MO1akOMa1IhW9yhtl02xGjag9pd1hreYEM9WztRXrwrzrM8dPEGO4oHJLlEQvgq5xoxO8BaCgYKAX8SARYSFQHGX2MiQLh3UOHBxP2eu5fLcEL5Fg0175", "expires_in": 3599, "refresh_token": "1//0efbA-jcfi1XZCgYIARAAGA4SNwF-L9IryppvK1Md7niUUz1nkTCkp8Skh6pVlL1PQe6eWTS8hL7Qorzw7UbmVx0sg9zgXs28pVo", "scope": "https://mail.google.com/", "token_type": "Bearer"}, "scopes": ["https://mail.google.com/"], "token_info_uri": "https://oauth2.googleapis.com/tokeninfo", "invalid": false, "_class": "OAuth2Credentials", "_module": "oauth2client.client"} \ No newline at end of file diff --git a/gmail_tokens/gmail_token_c3f7f4a3-3419-42a4-a2c4-6d490e3b50e0.json b/gmail_tokens/gmail_token_c3f7f4a3-3419-42a4-a2c4-6d490e3b50e0.json index 8903fb2..d3ef883 100644 --- a/gmail_tokens/gmail_token_c3f7f4a3-3419-42a4-a2c4-6d490e3b50e0.json +++ b/gmail_tokens/gmail_token_c3f7f4a3-3419-42a4-a2c4-6d490e3b50e0.json @@ -1 +1 @@ -{"access_token": "ya29.a0AZYkNZga-tjDnp1lsXRohu1Tji-eVV88RaLnPjxr3HpYuBDW_6boys1aqnRnete1pT-E7ygZ5drpb0Hhbt9o15ryqbfeaKqS4HTDG_iIVvFn3npNNLSqIdvsf98burhBOnR-Nf6ty7xCsPLyFaO15bG2LybRgGL1mubVNMXSaCgYKAdQSARISFQHGX2MicVi2eoShd196_WeptFDUZg0175", "client_id": "266164728215-v84lngbp3vgr4ulql01sqkg5vaigf4a5.apps.googleusercontent.com", "client_secret": "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", "refresh_token": "1//0eAXpVapw8WjjCgYIARAAGA4SNwF-L9Irm0iHkQzqzM7Hn39nctE-DOWKTsm89Ge3nG0bfdfqloRvLMiN4YWHEKcDpLdPIuZel0Q", "token_expiry": "2025-04-10T09:51:34Z", "token_uri": "https://oauth2.googleapis.com/token", "user_agent": null, "revoke_uri": "https://oauth2.googleapis.com/revoke", "id_token": null, "id_token_jwt": null, "token_response": {"access_token": "ya29.a0AZYkNZga-tjDnp1lsXRohu1Tji-eVV88RaLnPjxr3HpYuBDW_6boys1aqnRnete1pT-E7ygZ5drpb0Hhbt9o15ryqbfeaKqS4HTDG_iIVvFn3npNNLSqIdvsf98burhBOnR-Nf6ty7xCsPLyFaO15bG2LybRgGL1mubVNMXSaCgYKAdQSARISFQHGX2MicVi2eoShd196_WeptFDUZg0175", "expires_in": 3599, "refresh_token": "1//0eAXpVapw8WjjCgYIARAAGA4SNwF-L9Irm0iHkQzqzM7Hn39nctE-DOWKTsm89Ge3nG0bfdfqloRvLMiN4YWHEKcDpLdPIuZel0Q", "scope": "https://mail.google.com/", "token_type": "Bearer", "refresh_token_expires_in": 604799}, "scopes": ["https://mail.google.com/"], "token_info_uri": "https://oauth2.googleapis.com/tokeninfo", "invalid": false, "_class": "OAuth2Credentials", "_module": "oauth2client.client"} \ No newline at end of file +{"access_token": "ya29.a0AZYkNZgeGgZi3_OfGNwLC4ykCFeSfLHoth9GCDj_UJI9SvhY7nAuWl7Gf2qZH-4iUSeDX-fD6JWSAGPwK9rbNMCgX5RUbsExLA69L3XZ1Og5vE5U1HHePCdGSQ-Ceqxauob1tte2nQHdEaPaW5t9OLOQhwGXsCmGq2fhbNpgRgaCgYKAQ8SARYSFQHGX2Mi-I-L5Q--8gfUvX6_0juX1Q0177", "client_id": "266164728215-v84lngbp3vgr4ulql01sqkg5vaigf4a5.apps.googleusercontent.com", "client_secret": "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", "refresh_token": "1//0ed76IPW5-HYwCgYIARAAGA4SNwF-L9IrWvNjsRtcbhlXa0eSfKZsf1-pv5yWL6GSE-4ve6fPQNfIlP8ujruo9Y9B-eIZP6QjolY", "token_expiry": "2025-04-28T10:26:07Z", "token_uri": "https://oauth2.googleapis.com/token", "user_agent": null, "revoke_uri": "https://oauth2.googleapis.com/revoke", "id_token": null, "id_token_jwt": null, "token_response": {"access_token": "ya29.a0AZYkNZgeGgZi3_OfGNwLC4ykCFeSfLHoth9GCDj_UJI9SvhY7nAuWl7Gf2qZH-4iUSeDX-fD6JWSAGPwK9rbNMCgX5RUbsExLA69L3XZ1Og5vE5U1HHePCdGSQ-Ceqxauob1tte2nQHdEaPaW5t9OLOQhwGXsCmGq2fhbNpgRgaCgYKAQ8SARYSFQHGX2Mi-I-L5Q--8gfUvX6_0juX1Q0177", "expires_in": 3599, "scope": "https://mail.google.com/", "token_type": "Bearer"}, "scopes": ["https://mail.google.com/"], "token_info_uri": "https://oauth2.googleapis.com/tokeninfo", "invalid": false, "_class": "OAuth2Credentials", "_module": "oauth2client.client"} \ No newline at end of file diff --git a/media/exports/feishu_creators_c3f7f4a3-3419-42a4-a2c4-6d490e3b50e0.xlsx b/media/exports/feishu_creators_c3f7f4a3-3419-42a4-a2c4-6d490e3b50e0.xlsx new file mode 100644 index 0000000..ef72871 Binary files /dev/null and b/media/exports/feishu_creators_c3f7f4a3-3419-42a4-a2c4-6d490e3b50e0.xlsx differ diff --git a/media/videos/youtube_test_channel/1745812670_测试视频.mp4 b/media/videos/youtube_test_channel/1745812670_测试视频.mp4 new file mode 100644 index 0000000..4d9a5ee Binary files /dev/null and b/media/videos/youtube_test_channel/1745812670_测试视频.mp4 differ diff --git a/media/videos/youtube_test_channel/1745812800_测试视频.mp4 b/media/videos/youtube_test_channel/1745812800_测试视频.mp4 new file mode 100644 index 0000000..4d9a5ee Binary files /dev/null and b/media/videos/youtube_test_channel/1745812800_测试视频.mp4 differ diff --git a/media/videos/youtube_test_youtube1/1745814771_测试视频.mp4 b/media/videos/youtube_test_youtube1/1745814771_测试视频.mp4 new file mode 100644 index 0000000..4d9a5ee Binary files /dev/null and b/media/videos/youtube_test_youtube1/1745814771_测试视频.mp4 differ diff --git a/media/videos/youtube_test_youtube123/1745815240_测试视频.mp4 b/media/videos/youtube_test_youtube123/1745815240_测试视频.mp4 new file mode 100644 index 0000000..4d9a5ee Binary files /dev/null and b/media/videos/youtube_test_youtube123/1745815240_测试视频.mp4 differ diff --git a/models.py b/models.py new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/models.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/operation/__init__.py b/operation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/operation/admin.py b/operation/admin.py new file mode 100644 index 0000000..2406299 --- /dev/null +++ b/operation/admin.py @@ -0,0 +1,40 @@ +from django.contrib import admin +from user_management.models import OperatorAccount, PlatformAccount, Video + +@admin.register(OperatorAccount) +class OperatorAccountAdmin(admin.ModelAdmin): + list_display = ('username', 'real_name', 'position', 'department', 'is_active', 'created_at') + list_filter = ('position', 'department', 'is_active') + search_fields = ('username', 'real_name', 'email', 'phone') + ordering = ('-created_at',) + +@admin.register(PlatformAccount) +class PlatformAccountAdmin(admin.ModelAdmin): + 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', 'operator__real_name') + ordering = ('-created_at',) + + 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', '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_platform_name(self, obj): + return obj.platform_account.get_platform_name_display() + + def get_account_name(self, obj): + return obj.platform_account.account_name + + get_platform_name.short_description = '平台' + get_platform_name.admin_order_field = 'platform_account__platform_name' + get_account_name.short_description = '账号名称' + get_account_name.admin_order_field = 'platform_account__account_name' diff --git a/operation/apps.py b/operation/apps.py new file mode 100644 index 0000000..a862135 --- /dev/null +++ b/operation/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OperationConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'operation' diff --git a/operation/migrations/__init__.py b/operation/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/operation/models.py b/operation/models.py new file mode 100644 index 0000000..ebcfb88 --- /dev/null +++ b/operation/models.py @@ -0,0 +1,6 @@ +from django.db import models +from user_management.models import OperatorAccount, PlatformAccount, Video, KnowledgeBase, KnowledgeBaseDocument + +# Create your models here. + +# 我们可以在这里添加额外的模型或关系,但现在使用user_management中的现有模型 diff --git a/operation/serializers.py b/operation/serializers.py new file mode 100644 index 0000000..c161143 --- /dev/null +++ b/operation/serializers.py @@ -0,0 +1,100 @@ +from rest_framework import serializers +from user_management.models import OperatorAccount, PlatformAccount, Video, KnowledgeBase, KnowledgeBaseDocument +import uuid + + +class OperatorAccountSerializer(serializers.ModelSerializer): + id = serializers.UUIDField(read_only=False, required=False) # 允许前端不提供ID,但如果提供则必须是有效的UUID + + 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'] + 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) + + class Meta: + model = PlatformAccount + fields = ['id', 'operator', 'operator_name', 'platform_name', 'account_name', 'account_id', + 'status', 'followers_count', 'account_url', 'description', + 'created_at', 'updated_at', 'last_login'] + read_only_fields = ['id', 'created_at', 'updated_at'] + + def to_internal_value(self, data): + # 处理operator字段,可能是字符串格式的UUID + if 'operator' in data and isinstance(data['operator'], str): + try: + # 尝试获取对应的运营账号对象 + operator = OperatorAccount.objects.get(id=data['operator']) + data['operator'] = operator.id # 确保使用正确的ID格式 + except OperatorAccount.DoesNotExist: + # 如果找不到对应的运营账号,保持原值,让验证器捕获此错误 + pass + except Exception as e: + # 其他类型的错误,如ID格式不正确等 + pass + + return super().to_internal_value(data) + + +class VideoSerializer(serializers.ModelSerializer): + platform_account_name = serializers.CharField(source='platform_account.account_name', read_only=True) + platform_name = serializers.CharField(source='platform_account.platform_name', read_only=True) + + class Meta: + model = Video + 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'] + 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 + if 'platform_account' in data and isinstance(data['platform_account'], str): + try: + # 尝试获取对应的平台账号对象 + platform_account = PlatformAccount.objects.get(id=data['platform_account']) + data['platform_account'] = platform_account.id # 确保使用正确的ID格式 + except PlatformAccount.DoesNotExist: + # 如果找不到对应的平台账号,保持原值,让验证器捕获此错误 + pass + except Exception as e: + # 其他类型的错误,如ID格式不正确等 + pass + + return super().to_internal_value(data) + + +class KnowledgeBaseSerializer(serializers.ModelSerializer): + class Meta: + model = KnowledgeBase + fields = ['id', 'user_id', 'name', 'desc', 'type', 'department', 'group', + 'external_id', 'create_time', 'update_time'] + read_only_fields = ['id', 'create_time', 'update_time'] + + +class KnowledgeBaseDocumentSerializer(serializers.ModelSerializer): + class Meta: + model = KnowledgeBaseDocument + fields = ['id', 'knowledge_base', 'document_id', 'document_name', + 'external_id', 'uploader_name', 'status', 'create_time', 'update_time'] + read_only_fields = ['id', 'create_time', 'update_time'] \ No newline at end of file diff --git a/operation/tests.py b/operation/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/operation/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/operation/urls.py b/operation/urls.py new file mode 100644 index 0000000..e6a3b82 --- /dev/null +++ b/operation/urls.py @@ -0,0 +1,12 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import OperatorAccountViewSet, PlatformAccountViewSet, VideoViewSet + +router = DefaultRouter() +router.register(r'operators', OperatorAccountViewSet) +router.register(r'platforms', PlatformAccountViewSet) +router.register(r'videos', VideoViewSet) + +urlpatterns = [ + path('', include(router.urls)), +] \ No newline at end of file diff --git a/operation/views.py b/operation/views.py new file mode 100644 index 0000000..7b8120e --- /dev/null +++ b/operation/views.py @@ -0,0 +1,857 @@ +from django.shortcuts import render +import json +import uuid +import logging +from django.db import transaction +from django.shortcuts import get_object_or_404 +from django.conf import settings +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 user_management.models import OperatorAccount, PlatformAccount, Video, KnowledgeBase, KnowledgeBaseDocument, User +from .serializers import ( + OperatorAccountSerializer, PlatformAccountSerializer, VideoSerializer, + KnowledgeBaseSerializer, KnowledgeBaseDocumentSerializer +) + +logger = logging.getLogger(__name__) + +class OperatorAccountViewSet(viewsets.ModelViewSet): + """运营账号管理视图集""" + queryset = OperatorAccount.objects.all() + serializer_class = OperatorAccountSerializer + permission_classes = [IsAuthenticated] + + 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) + + 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": "运营账号已停用,相关知识库文档已标记为删除", + "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): + """平台账号管理视图集""" + queryset = PlatformAccount.objects.all() + serializer_class = PlatformAccountSerializer + permission_classes = [IsAuthenticated] + + def create(self, request, *args, **kwargs): + """创建平台账号并记录到知识库""" + with transaction.atomic(): + # 处理operator字段,可能是字符串类型的ID + data = request.data.copy() + 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) + except (ValueError, OperatorAccount.DoesNotExist): + # 如果无法转换为整数或找不到对应账号,尝试通过用户名或真实姓名查找 + operator = OperatorAccount.objects.filter( + Q(username=operator_id) | Q(real_name=operator_id) + ).first() + + if not operator: + return Response({ + "code": 404, + "message": f"未找到运营账号: {operator_id},请提供有效的ID、用户名或真实姓名", + "data": None + }, status=status.HTTP_404_NOT_FOUND) + + # 更新请求数据中的operator字段为找到的operator的ID + data['operator'] = operator.id + + except Exception as e: + return Response({ + "code": 400, + "message": f"处理运营账号ID时出错: {str(e)}", + "data": None + }, status=status.HTTP_400_BAD_REQUEST) + + # 创建平台账号 + 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) + + # 获取关联的运营账号 + 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.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 + ) + + return Response({ + "code": 200, + "message": "平台账号创建成功,并已添加到知识库", + "data": self.get_serializer(platform_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": "平台账号已删除,相关知识库文档已标记为删除", + "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') + + if not followers_count: + return Response({ + "code": 400, + "message": "粉丝数不能为空", + "data": None + }, status=status.HTTP_400_BAD_REQUEST) + + # 更新粉丝数 + 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}") + + return Response({ + "code": 200, + "message": "粉丝数更新成功", + "data": { + "id": platform_account.id, + "account_name": platform_account.account_name, + "followers_count": platform_account.followers_count + } + }) + + +class VideoViewSet(viewsets.ModelViewSet): + """视频管理视图集""" + queryset = Video.objects.all() + serializer_class = VideoSerializer + permission_classes = [IsAuthenticated] + + def create(self, request, *args, **kwargs): + """创建视频并记录到知识库""" + with transaction.atomic(): + # 处理platform_account字段,可能是字符串类型的ID + data = request.data.copy() + if 'platform_account' in data and isinstance(data['platform_account'], str): + try: + # 尝试通过ID查找平台账号 + platform_id = data['platform_account'] + try: + # 先尝试通过整数ID查找 + platform_id_int = int(platform_id) + platform = PlatformAccount.objects.get(id=platform_id_int) + except (ValueError, PlatformAccount.DoesNotExist): + # 如果无法转换为整数或找不到对应账号,尝试通过账号名称或账号ID查找 + platform = PlatformAccount.objects.filter( + Q(account_name=platform_id) | Q(account_id=platform_id) + ).first() + + if not platform: + return Response({ + "code": 404, + "message": f"未找到平台账号: {platform_id},请提供有效的ID、账号名称或账号ID", + "data": None + }, status=status.HTTP_404_NOT_FOUND) + + # 更新请求数据中的platform_account字段为找到的platform的ID + data['platform_account'] = platform.id + + except Exception as e: + return Response({ + "code": 400, + "message": f"处理平台账号ID时出错: {str(e)}", + "data": None + }, status=status.HTTP_400_BAD_REQUEST) + + # 创建视频 + 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 + ) + + return Response({ + "code": 200, + "message": "视频创建成功,并已添加到知识库", + "data": self.get_serializer(video).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": "视频记录已删除,相关知识库文档已标记为删除", + "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() + + # 获取更新的统计数据 + stats = {} + for field in ['views_count', 'likes_count', 'comments_count', 'shares_count']: + if field in request.data: + stats[field] = request.data[field] + + if not stats: + return Response({ + "code": 400, + "message": "没有提供任何统计数据", + "data": None + }, status=status.HTTP_400_BAD_REQUEST) + + # 更新视频统计数据 + for field, value in stats.items(): + 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": "视频统计数据更新成功", + "data": { + "id": video.id, + "title": video.title, + "views_count": video.views_count, + "likes_count": video.likes_count, + "comments_count": video.comments_count, + "shares_count": video.shares_count + } + }) + + @action(detail=True, methods=['post']) + def publish(self, request, pk=None): + """发布视频并更新状态""" + video = self.get_object() + + # 检查视频状态 + if video.status not in ['draft', 'scheduled']: + return Response({ + "code": 400, + "message": f"当前视频状态为 {video.get_status_display()},无法发布", + "data": None + }, status=status.HTTP_400_BAD_REQUEST) + + # 获取视频URL + video_url = request.data.get('video_url') + if not video_url: + return Response({ + "code": 400, + "message": "未提供视频URL", + "data": None + }, status=status.HTTP_400_BAD_REQUEST) + + # 更新视频状态和URL + video.video_url = video_url + video.status = 'published' + 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": "视频已成功发布", + "data": { + "id": video.id, + "title": video.title, + "status": video.status, + "video_url": video.video_url, + "publish_time": video.publish_time + } + }) + + @action(detail=False, methods=['post']) + def upload_video(self, request): + """上传视频文件并创建视频记录""" + try: + # 获取上传的视频文件 + video_file = request.FILES.get('video_file') + if not video_file: + return Response({ + "code": 400, + "message": "未提供视频文件", + "data": None + }, status=status.HTTP_400_BAD_REQUEST) + + # 获取平台账号ID + platform_account_id = request.data.get('platform_account') + if not platform_account_id: + return Response({ + "code": 400, + "message": "未提供平台账号ID", + "data": None + }, status=status.HTTP_400_BAD_REQUEST) + + try: + platform_account = PlatformAccount.objects.get(id=platform_account_id) + except PlatformAccount.DoesNotExist: + return Response({ + "code": 404, + "message": f"未找到ID为{platform_account_id}的平台账号", + "data": None + }, status=status.HTTP_404_NOT_FOUND) + + # 创建保存视频的目录 + import os + from django.conf import settings + + # 确保文件保存目录存在 + media_root = getattr(settings, 'MEDIA_ROOT', os.path.join(settings.BASE_DIR, 'media')) + videos_dir = os.path.join(media_root, 'videos') + account_dir = os.path.join(videos_dir, f"{platform_account.platform_name}_{platform_account.account_name}") + + if not os.path.exists(videos_dir): + os.makedirs(videos_dir) + if not os.path.exists(account_dir): + os.makedirs(account_dir) + + # 生成唯一的文件名 + import time + timestamp = int(time.time()) + file_name = f"{timestamp}_{video_file.name}" + file_path = os.path.join(account_dir, file_name) + + # 保存视频文件 + with open(file_path, 'wb+') as destination: + for chunk in video_file.chunks(): + destination.write(chunk) + + # 创建视频记录 + video_data = { + 'platform_account': platform_account, + 'title': request.data.get('title', os.path.splitext(video_file.name)[0]), + 'description': request.data.get('description', ''), + 'local_path': file_path, + 'status': 'draft', + '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": "视频上传成功", + "data": { + "id": video.id, + "title": video.title, + "status": video.get_status_display(), + "scheduled_time": video.scheduled_time + } + }, status=status.HTTP_201_CREATED) + + 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) + + 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): + """手动发布视频""" + video = self.get_object() + + # 检查视频状态是否允许发布 + if video.status not in ['draft', 'scheduled']: + return Response({ + "code": 400, + "message": f"当前视频状态为 {video.get_status_display()},无法发布", + "data": None + }, status=status.HTTP_400_BAD_REQUEST) + + # 检查视频文件是否存在 + if not video.local_path or not os.path.exists(video.local_path): + return Response({ + "code": 400, + "message": "视频文件不存在,无法发布", + "data": None + }, status=status.HTTP_400_BAD_REQUEST) + + # 执行发布任务 + try: + from user_management.tasks import publish_scheduled_video + result = publish_scheduled_video(video.id) + + if isinstance(result, dict) and result.get('success', False): + return Response({ + "code": 200, + "message": "视频发布成功", + "data": { + "id": video.id, + "title": video.title, + "status": "published", + "video_url": result.get('video_url'), + "publish_time": result.get('publish_time') + } + }) + else: + return Response({ + "code": 500, + "message": f"发布失败: {result.get('error', '未知错误')}", + "data": None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + 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) diff --git a/requirements.txt b/requirements.txt index 05e6f06..54f036d 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/role_based_system/__init__.py b/role_based_system/__init__.py index e69de29..3fde4d8 100644 --- a/role_based_system/__init__.py +++ b/role_based_system/__init__.py @@ -0,0 +1,4 @@ +# 让Celery在Django启动时自动加载 +from .celery import app as celery_app + +__all__ = ['celery_app'] diff --git a/role_based_system/asgi.py b/role_based_system/asgi.py index 714024b..87a4179 100644 --- a/role_based_system/asgi.py +++ b/role_based_system/asgi.py @@ -8,21 +8,18 @@ https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ """ import os -import django - -# 首先设置 Django 设置模块 -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'role_based_system.settings') -django.setup() # 添加这行来初始化 Django - -# 然后再导入其他模块 from django.core.asgi import get_asgi_application from channels.routing import ProtocolTypeRouter, URLRouter from channels.auth import AuthMiddlewareStack from user_management.routing import websocket_urlpatterns +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'role_based_system.settings') + application = ProtocolTypeRouter({ "http": get_asgi_application(), "websocket": AuthMiddlewareStack( - URLRouter(websocket_urlpatterns) + URLRouter( + websocket_urlpatterns + ) ), }) \ No newline at end of file diff --git a/role_based_system/celery.py b/role_based_system/celery.py new file mode 100644 index 0000000..7b92c88 --- /dev/null +++ b/role_based_system/celery.py @@ -0,0 +1,27 @@ +import os +from celery import Celery +from celery.schedules import crontab + +# 设置默认Django设置模块 +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'role_based_system.settings') + +app = Celery('role_based_system') + +# 使用字符串表示,避免pickle序列化可能带来的安全问题 +app.config_from_object('django.conf:settings', namespace='CELERY') + +# 自动从所有注册的Django应用中加载任务 +app.autodiscover_tasks() + +# 配置定期任务 +app.conf.beat_schedule = { + # 每小时检查一次未发布的视频 + 'check-scheduled-videos-every-hour': { + 'task': 'user_management.tasks.check_scheduled_videos', + 'schedule': crontab(minute=0, hour='*/1'), # 每小时运行一次 + }, +} + +@app.task(bind=True, ignore_result=True) +def debug_task(self): + print(f'Request: {self.request!r}') \ No newline at end of file diff --git a/role_based_system/settings.py b/role_based_system/settings.py index b00e85e..91c0862 100644 --- a/role_based_system/settings.py +++ b/role_based_system/settings.py @@ -58,6 +58,7 @@ INSTALLED_APPS = [ 'user_management', 'channels_redis', 'corsheaders', + 'operation', # 添加新的运营管理应用 ] MIDDLEWARE = [ @@ -168,15 +169,14 @@ ASGI_APPLICATION = "role_based_system.asgi.application" # Channel Layers 配置 CHANNEL_LAYERS = { "default": { - "BACKEND": "channels_redis.core.RedisChannelLayer", - "CONFIG": { - "hosts": [("127.0.0.1", 6379)], - "capacity": 1500, # 消息队列容量 - "expiry": 10, # 消息过期时间(秒) - }, - }, + "BACKEND": "channels.layers.InMemoryChannelLayer" + } } +# Redis 配置 +REDIS_HOST = '127.0.0.1' +REDIS_PORT = 6379 +REDIS_DB = 0 # CORS 配置 CORS_ALLOW_ALL_ORIGINS = True @@ -185,9 +185,9 @@ CORS_ALLOWED_ORIGINS = [ "http://localhost:8000", "http://127.0.0.1:8000", "http://124.222.236.141:8000", - "ws://localhost:8000", # 添加 WebSocket - "ws://127.0.0.1:8000", # 添加 WebSocket - "ws://124.222.236.141:8000", # 添加 WebSocket + "ws://localhost:8000", + "ws://127.0.0.1:8000", + "ws://124.222.236.141:8000", ] # 允许的请求头 CORS_ALLOWED_HEADERS = [ @@ -200,6 +200,7 @@ CORS_ALLOWED_HEADERS = [ 'user-agent', 'x-csrftoken', 'x-requested-with', + 'token', # 添加token头 ] # 允许的请求方法 @@ -301,7 +302,7 @@ GMAIL_API_SCOPES = ['https://mail.google.com/'] GMAIL_TOPIC_NAME = 'gmail-watch-topic' # Gmail webhook地址 (开发环境使用本机内网穿透地址) -GMAIL_WEBHOOK_URL = 'https://a7a4-116-227-35-74.ngrok-free.app/api/user/gmail/webhook/' +GMAIL_WEBHOOK_URL = 'https://27b3-180-159-100-165.ngrok-free.app/api/user/gmail/webhook/' # 如果在生产环境,使用以下固定地址 # GMAIL_WEBHOOK_URL = 'https://你的域名/api/user/gmail/webhook/' @@ -317,7 +318,15 @@ USE_L10N = True USE_TZ = True # 将此项设置为True以启用时区支持 # DeepSeek API配置 -DEEPSEEK_API_KEY = "sk-xqbujijjqqmlmlvkhvxeogqjtzslnhdtqxqgiyuhwpoqcjvf" -# 注意:这里需要更新为有效的DeepSeek API密钥 +DEEPSEEK_API_KEY = "sk-xqbujijjqqmlmlvkhvxeogqjtzslnhdtqxqgiyuhwpoqcjvf" # 请替换为您的实际有效的DeepSeek API密钥 +SILICON_CLOUD_API_KEY = 'sk-xqbujijjqqmlmlvkhvxeogqjtzslnhdtqxqgiyuhwpoqcjvf' +# Celery配置 +CELERY_BROKER_URL = 'redis://localhost:6379/0' +CELERY_RESULT_BACKEND = 'redis://localhost:6379/0' +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_TIMEZONE = TIME_ZONE +CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True diff --git a/role_based_system/urls.py b/role_based_system/urls.py index 5fd4f74..198fee4 100644 --- a/role_based_system/urls.py +++ b/role_based_system/urls.py @@ -19,7 +19,11 @@ from django.urls import path, include from django.conf import settings from django.conf.urls.static import static from user_management.views import gmail_webhook, feishu_sync_api, feishu_to_kb_api, check_creator_kb_api -from user_management.feishu_chat_views import process_feishu_table, run_auto_chat, feishu_user_goal, check_goal_status +from user_management.feishu_chat_views import ( + process_feishu_table, run_auto_chat, feishu_user_goal, check_goal_status, + export_creators_data, download_exported_file +) +from feishu.feishu_ai_chat import api_export_talent_replies urlpatterns = [ # 管理后台 @@ -28,10 +32,13 @@ urlpatterns = [ # API路由 path('api/', include('user_management.urls')), + # 运营管理API路由 + path('api/operation/', include('operation.urls')), + # 专用Gmail Webhook路由 - 直接匹配根路径 path('api/user/gmail/webhook/', gmail_webhook, name='root_gmail_webhook'), # 修改为正确路径 + path('api/user/', include('user_management.urls')), path('gmail/webhook/', gmail_webhook, name='alt_gmail_webhook'), # 添加备用路径 - # 飞书相关API path('api/feishu/sync', feishu_sync_api, name='feishu_sync_api'), path('api/feishu/to_kb', feishu_to_kb_api, name='feishu_to_kb_api'), @@ -43,6 +50,14 @@ urlpatterns = [ path('api/feishu/user-goal/', feishu_user_goal, name='direct_feishu_user_goal'), path('api/feishu/check-goal/', check_goal_status, name='direct_check_goal_status'), + # 导出数据API + path('api/feishu/export-data/', export_creators_data, name='export_creators_data'), + path('api/feishu/download//', download_exported_file, name='download_exported_file'), + + # 导出达人回复API + path('api/users/talent-replies/export/', api_export_talent_replies, name='export_talent_replies'), + path('api/export-talent-replies/', api_export_talent_replies, name='alt_export_talent_replies'), + # 媒体文件服务 *static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT), diff --git a/role_based_system/wsgi.py b/role_based_system/wsgi.py index 171b44b..d79a8e2 100644 --- a/role_based_system/wsgi.py +++ b/role_based_system/wsgi.py @@ -1,16 +1,33 @@ """ -WSGI config for role_based_system project. +ASGI config for role_based_system project. -It exposes the WSGI callable as a module-level variable named ``application``. +It exposes the ASGI callable as a module-level variable named ``application``. For more information on this file, see -https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ +https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ """ import os +import django -from django.core.wsgi import get_wsgi_application - +# 首先设置 Django 设置模块 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'role_based_system.settings') +django.setup() # 添加这行来初始化 Django -application = get_wsgi_application() +# 然后再导入其他模块 +from django.core.asgi import get_asgi_application +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.auth import AuthMiddlewareStack +from channels.security.websocket import AllowedHostsOriginValidator +from user_management.routing import websocket_urlpatterns +from user_management.middleware import TokenAuthMiddleware + +# 使用TokenAuthMiddleware代替AuthMiddlewareStack +application = ProtocolTypeRouter({ + "http": get_asgi_application(), + "websocket": AllowedHostsOriginValidator( + TokenAuthMiddleware( + URLRouter(websocket_urlpatterns) + ) + ), +}) \ No newline at end of file diff --git a/talent_replies_20250428_183133.xlsx b/talent_replies_20250428_183133.xlsx new file mode 100644 index 0000000..729c220 Binary files /dev/null and b/talent_replies_20250428_183133.xlsx differ diff --git a/talent_replies_20250428_183234.xlsx b/talent_replies_20250428_183234.xlsx new file mode 100644 index 0000000..1b4f116 Binary files /dev/null and b/talent_replies_20250428_183234.xlsx differ diff --git a/temp/gmail_webhook_updated.py b/temp/gmail_webhook_updated.py new file mode 100644 index 0000000..67c60ba --- /dev/null +++ b/temp/gmail_webhook_updated.py @@ -0,0 +1,463 @@ +@api_view(['POST']) +@permission_classes([]) +def gmail_webhook(request): + """Gmail推送通知webhook""" + try: + # 导入需要的模块 + import logging + import traceback + from django.utils import timezone + from django.contrib.auth import get_user_model + from rest_framework import status + from rest_framework.response import Response + + # 获取用户模型 + User = get_user_model() + + # 导入Gmail集成相关的模块 + from .models import GmailCredential + from .gmail_integration import GmailIntegration, GmailServiceManager + + logger = logging.getLogger(__name__) + + # 添加更详细的日志 + logger.info(f"接收到Gmail webhook请求: 路径={request.path}, 方法={request.method}") + logger.info(f"请求头: {dict(request.headers)}") + logger.info(f"请求数据: {request.data}") + + # 验证请求来源(可以添加额外的安全校验) + data = request.data + + if not data: + return Response({ + 'code': 400, + 'message': '无效的请求数据', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 处理数据 + email_address = None + history_id = None + + # 处理Google Pub/Sub消息格式 + if isinstance(data, dict) and 'message' in data and 'data' in data['message']: + try: + import base64 + import json + logger.info("检测到Google Pub/Sub消息格式") + + # Base64解码data字段 + encoded_data = data['message']['data'] + decoded_data = base64.b64decode(encoded_data).decode('utf-8') + logger.info(f"解码后的数据: {decoded_data}") + + # 解析JSON获取email和historyId + json_data = json.loads(decoded_data) + email_address = json_data.get('emailAddress') + history_id = json_data.get('historyId') + logger.info(f"从Pub/Sub消息中提取: email={email_address}, historyId={history_id}") + except Exception as decode_error: + logger.error(f"解析Pub/Sub消息失败: {str(decode_error)}") + logger.error(traceback.format_exc()) + # 处理其他格式的数据 + elif isinstance(data, dict): + # 直接使用JSON格式数据 + logger.info("接收到JSON格式数据") + email_address = data.get('emailAddress') + history_id = data.get('historyId') + elif hasattr(data, 'decode'): + # 尝试解析原始数据 + logger.info("接收到原始数据格式,尝试解析") + try: + import json + json_data = json.loads(data.decode('utf-8')) + email_address = json_data.get('emailAddress') + history_id = json_data.get('historyId') + except Exception as parse_error: + logger.error(f"解析请求数据失败: {str(parse_error)}") + email_address = None + history_id = None + else: + # 尝试从请求参数获取 + logger.info("尝试从请求参数获取数据") + email_address = request.GET.get('emailAddress') or request.POST.get('emailAddress') + history_id = request.GET.get('historyId') or request.POST.get('historyId') + + logger.info(f"提取的邮箱: {email_address}, 历史ID: {history_id}") + + if not email_address or not history_id: + return Response({ + 'code': 400, + 'message': '缺少必要的参数', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 查找用户和认证信息 - 优化的查找逻辑 + user = None + credential = None + + # 1. 首先尝试直接通过Gmail凭证表查找 + credential = GmailCredential.objects.filter( + gmail_email=email_address, + is_active=True + ).select_related('user').order_by('-is_default', '-updated_at').first() + + if credential: + user = credential.user + logger.info(f"通过gmail_email直接找到用户和凭证: 用户={user.email}, 凭证ID={credential.id}") + else: + # 2. 如果没找到,尝试通过用户邮箱查找 + user = User.objects.filter(email=email_address).first() + + if user: + logger.info(f"通过用户邮箱找到用户: {user.email}") + + # 为该用户查找任何有效的Gmail凭证 + credential = GmailCredential.objects.filter( + user=user, + is_active=True + ).order_by('-is_default', '-updated_at').first() + + if credential: + logger.info(f"为用户 {user.email} 找到有效的Gmail凭证: {credential.id}") + else: + logger.error(f"无法找到与{email_address}关联的用户或凭证") + return Response({ + 'code': 404, + 'message': f'找不到与 {email_address} 关联的用户', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) + + if not credential: + logger.error(f"用户 {user.email} 没有有效的Gmail凭证") + return Response({ + 'code': 404, + 'message': f'找不到用户 {user.email} 的Gmail凭证', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) + + # 更新history_id,无论如何都记录这次历史ID + credential.last_history_id = history_id + credential.save() + + # 清除可能存在的缓存实例,确保使用最新凭证 + GmailServiceManager.clear_instance(user, str(credential.id)) + + # 检查凭证是否需要重新授权 + notification_queued = False + if credential.needs_reauth or not credential.credentials: + logger.warning(f"Gmail凭证需要重新授权,将通知保存到队列: {email_address}") + + # 保存到通知队列 + from .models import GmailNotificationQueue + import json + + # 将通知数据序列化 + try: + notification_json = json.dumps(data) + except: + notification_json = f'{{"emailAddress": "{email_address}", "historyId": "{history_id}"}}' + + # 创建队列记录 + GmailNotificationQueue.objects.create( + user=user, + gmail_credential=credential, + email=email_address, + history_id=str(history_id), + notification_data=notification_json, + processed=False + ) + + logger.info(f"Gmail通知已保存到队列,等待用户重新授权: {email_address}") + notification_queued = True + + # 直接返回成功,但记录需要用户重新授权 + return Response({ + 'code': 202, # Accepted + 'message': '通知已保存到队列,等待用户重新授权', + 'data': { + 'user_id': str(user.id), + 'history_id': history_id, + 'needs_reauth': True + } + }) + + # 如果请求中包含达人邮箱,直接处理特定达人的邮件 + talent_email = data.get('talent_email') or request.GET.get('talent_email') + + if talent_email and user: + logger.info(f"检测到特定达人邮箱: {talent_email},将直接处理其最近邮件") + try: + # 创建Gmail集成实例 - 使用明确的凭证ID + integration = GmailIntegration(user=user, gmail_credential_id=str(credential.id)) + if integration.authenticate(): + # 获取达人最近的邮件 + recent_emails = integration.get_recent_emails( + from_email=talent_email, + max_results=5 # 限制获取最近5封 + ) + + if recent_emails: + logger.info(f"找到 {len(recent_emails)} 封来自 {talent_email} 的最近邮件") + # 创建或获取知识库 + knowledge_base, created = integration.create_talent_knowledge_base(talent_email) + # 保存对话 + result = integration.save_conversations_to_knowledge_base(recent_emails, knowledge_base) + logger.info(f"已处理达人 {talent_email} 的最近邮件: {result}") + else: + logger.info(f"没有找到来自 {talent_email} 的最近邮件") + else: + logger.error("Gmail认证失败,无法处理特定达人邮件") + # 如果还没有保存到队列,保存通知数据 + if not notification_queued: + # 保存到通知队列 + from .models import GmailNotificationQueue + import json + + try: + notification_json = json.dumps(data) + except: + notification_json = f'{{"emailAddress": "{email_address}", "historyId": "{history_id}", "talent_email": "{talent_email}"}}' + + GmailNotificationQueue.objects.create( + user=user, + gmail_credential=credential, + email=email_address, + history_id=str(history_id), + notification_data=notification_json, + processed=False + ) + logger.info(f"Gmail通知(含达人邮箱)已保存到队列: {email_address}, 达人: {talent_email}") + except Exception as talent_error: + logger.error(f"处理达人邮件失败: {str(talent_error)}") + logger.error(traceback.format_exc()) + + # 处理普通通知 + try: + # 创建Gmail集成实例 - 明确使用找到的凭证ID + integration = GmailIntegration(user=user, gmail_credential_id=str(credential.id)) + + # 记录详细的凭证信息,帮助排查问题 + logger.info(f"处理普通通知: 用户ID={user.id}, 凭证ID={credential.id}, Gmail邮箱={credential.gmail_email}") + + auth_success = integration.authenticate() + + if auth_success: + logger.info(f"Gmail认证成功,开始处理通知: {email_address}") + + # 强制设置最小历史ID差值,确保能获取新消息 + try: + # 从凭证中获取历史ID,并确保作为整数比较 + last_history_id = int(credential.last_history_id or 0) + current_history_id = int(history_id) + + # 如果历史ID没有变化,设置一个小的偏移量确保获取最近消息 + if current_history_id <= last_history_id: + # 设置较小的历史ID以确保获取最近的消息 + adjusted_history_id = max(1, last_history_id - 10) + logger.info(f"调整历史ID: {last_history_id} -> {adjusted_history_id},以确保能获取最近的消息") + # 修改请求中的历史ID + if isinstance(data, dict) and 'message' in data and 'data' in data['message']: + # 对于Pub/Sub格式,修改解码后的JSON + try: + import base64 + import json + decoded_data = base64.b64decode(data['message']['data']).decode('utf-8') + json_data = json.loads(decoded_data) + json_data['historyId'] = str(adjusted_history_id) + data['message']['data'] = base64.b64encode(json.dumps(json_data).encode('utf-8')).decode('utf-8') + logger.info(f"已调整Pub/Sub消息中的历史ID为: {adjusted_history_id}") + except Exception as adjust_error: + logger.error(f"调整历史ID失败: {str(adjust_error)}") + else: + # 直接修改data中的historyId + if isinstance(data, dict) and 'historyId' in data: + data['historyId'] = str(adjusted_history_id) + logger.info(f"已调整请求中的历史ID为: {adjusted_history_id}") + except Exception as history_adjust_error: + logger.error(f"历史ID调整失败: {str(history_adjust_error)}") + + result = integration.process_notification(data) + + # 日志记录处理结果 + if result: + logger.info(f"Gmail通知处理成功,检测到新消息: {email_address}") + else: + logger.warning(f"Gmail通知处理完成,但未检测到新消息: {email_address}") + + # 如果处理成功,尝试通过WebSocket发送通知 + if result: + try: + from channels.layers import get_channel_layer + from asgiref.sync import async_to_sync + + # 获取Channel Layer + channel_layer = get_channel_layer() + if channel_layer: + # 发送WebSocket消息 + async_to_sync(channel_layer.group_send)( + f"notification_user_{user.id}", + { + "type": "notification", + "data": { + "message_type": "gmail_update", + "message": "您的Gmail有新消息,已自动处理", + "history_id": history_id, + "timestamp": timezone.now().isoformat() + } + } + ) + logger.info(f"发送WebSocket通知成功: user_id={user.id}") + except Exception as ws_error: + logger.error(f"发送WebSocket通知失败: {str(ws_error)}") + + logger.info(f"Gmail通知处理成功: {email_address}") + return Response({ + 'code': 200, + 'message': '通知已处理', + 'data': { + 'user_id': str(user.id), + 'history_id': history_id, + 'success': True, + 'new_messages': result + } + }) + else: + # 认证失败,保存通知到队列 + logger.error(f"Gmail认证失败: {email_address}, 用户ID={user.id}, 凭证ID={credential.id}") + + # 尝试获取详细的认证失败原因 + try: + # 尝试刷新令牌 + refresh_result = integration.refresh_token() + if refresh_result: + logger.info(f"令牌刷新成功,将重新尝试处理") + result = integration.process_notification(data) + if result: + logger.info(f"刷新令牌后处理成功!") + return Response({ + 'code': 200, + 'message': '通知已处理(令牌刷新后)', + 'data': { + 'user_id': str(user.id), + 'history_id': history_id, + 'success': True + } + }) + except Exception as refresh_error: + logger.error(f"尝试刷新令牌失败: {str(refresh_error)}") + + # 如果还没有保存到队列,保存通知数据 + if not notification_queued: + # 保存到通知队列 + from .models import GmailNotificationQueue + import json + + try: + notification_json = json.dumps(data) + except: + notification_json = f'{{"emailAddress": "{email_address}", "historyId": "{history_id}"}}' + + # 标记凭证需要重新授权 + credential.needs_reauth = True + credential.save() + logger.info(f"已标记凭证 {credential.id} 需要重新授权") + + GmailNotificationQueue.objects.create( + user=user, + gmail_credential=credential, + email=email_address, + history_id=str(history_id), + notification_data=notification_json, + processed=False + ) + logger.info(f"Gmail通知已保存到队列: {email_address}") + + # 返回处理成功,但告知需要重新授权 + return Response({ + 'code': 202, # Accepted + 'message': '通知已保存到队列,等待重新获取授权', + 'data': { + 'user_id': str(user.id), + 'history_id': history_id, + 'needs_reauth': True + } + }) + except Exception as process_error: + logger.error(f"处理Gmail通知失败: {str(process_error)}") + logger.error(traceback.format_exc()) + + # 保存到通知队列 + if not notification_queued: + try: + from .models import GmailNotificationQueue + import json + + try: + notification_json = json.dumps(data) + except: + notification_json = f'{{"emailAddress": "{email_address}", "historyId": "{history_id}"}}' + + # 标记凭证需要重新授权 - 可能是令牌问题导致的错误 + error_msg = str(process_error).lower() + if "invalid_grant" in error_msg or "token" in error_msg or "auth" in error_msg or "认证" in error_msg: + credential.needs_reauth = True + credential.save() + logger.info(f"根据错误信息标记凭证 {credential.id} 需要重新授权") + + GmailNotificationQueue.objects.create( + user=user, + gmail_credential=credential, + email=email_address, + history_id=str(history_id), + notification_data=notification_json, + processed=False, + error_message=str(process_error)[:255] + ) + logger.info(f"由于处理错误,Gmail通知已保存到队列: {email_address}") + except Exception as queue_error: + logger.error(f"保存通知到队列失败: {str(queue_error)}") + + # 仍然返回成功,防止Google重试导致重复通知 + return Response({ + 'code': 202, + 'message': '通知已保存,稍后处理', + 'data': { + 'user_id': str(user.id), + 'history_id': history_id, + 'error': str(process_error)[:100] # 截断错误信息 + } + }) + + except Exception as e: + logger.error(f"处理Gmail webhook失败: {str(e)}") + logger.error(traceback.format_exc()) + + # 尝试更安全的响应,尽可能提供有用信息 + try: + # 如果已经提取了邮箱和历史ID等信息,记录在响应中 + response_data = { + 'error': str(e)[:200] + } + + if 'email_address' in locals() and email_address: + response_data['email_address'] = email_address + + if 'history_id' in locals() and history_id: + response_data['history_id'] = history_id + + if 'user' in locals() and user: + response_data['user_id'] = str(user.id) + + return Response({ + 'code': 500, + 'message': '处理通知失败', + 'data': response_data + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + except: + # 最后的备用方案 + return Response({ + 'code': 500, + 'message': '处理通知失败', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) \ No newline at end of file diff --git a/test_config.py b/test_config.py new file mode 100644 index 0000000..2ee657f --- /dev/null +++ b/test_config.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +"""测试配置文件""" + +# API配置 +API_BASE_URL = "http://127.0.0.1:8000" +AUTH_TOKEN = "7831a86588bc08d025e4c9bd668de3b7940f7634" + +# 请求头配置 +HEADERS = { + "Authorization": f"Token {AUTH_TOKEN}", + "User-Agent": "Apifox/1.0.0 (https://apifox.com)", + "Content-Type": "application/json", + "Accept": "*/*", + "Host": "127.0.0.1:8000", + "Connection": "keep-alive", + "Cookie": "csrftoken=FIYybrNUqefEo2z9QyozmYqQhxTMSFPo; sessionid=ckvdyvy4vzsyfzxg7fie7xbhmxboqegv" +} + +# 测试用例 +TEST_CASES = [ + { + "name": "基础总结测试", + "question": "总结下", + "conversation_id": "10b34248-2625-434b-a493-6d43520c837a", + "dataset_id": "8390ca43-6e63-4df9-b0b9-6cb20e1b38af", + "expected_response_time": 10.0 + }, + { + "name": "空内容测试", + "question": "", + "conversation_id": "10b34248-2625-434b-a493-6d43520c837a", + "dataset_id": "8390ca43-6e63-4df9-b0b9-6cb20e1b38af", + "expected_error": True + }, + { + "name": "中文问答测试", + "question": "Python是什么?", + "conversation_id": "10b34248-2625-434b-a493-6d43520c837a", + "dataset_id": "8390ca43-6e63-4df9-b0b9-6cb20e1b38af", + "expected_response_time": 10.0 + } +] + +# 日志配置 +LOG_CONFIG = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'standard': { + 'format': '%(asctime)s [%(levelname)s] %(message)s', + 'datefmt': '%Y-%m-%d %H:%M:%S' + }, + }, + 'handlers': { + 'console': { + 'level': 'DEBUG', + 'formatter': 'standard', + 'class': 'logging.StreamHandler', + }, + 'file': { + 'level': 'DEBUG', + 'formatter': 'standard', + 'class': 'logging.FileHandler', + 'filename': 'stream_test.log', + 'mode': 'w', + 'encoding': 'utf-8', + } + }, + 'loggers': { + 'stream_test': { + 'handlers': ['console', 'file'], + 'level': 'DEBUG', + 'propagate': True + } + } +} \ No newline at end of file diff --git a/test_external_api.py b/test_external_api.py new file mode 100644 index 0000000..c6bc21c --- /dev/null +++ b/test_external_api.py @@ -0,0 +1,129 @@ +import requests +import json +import sys +import time +from datetime import datetime + +# 外部API地址 +API_URL = "http://81.69.223.133:48329/api/application/chat_message/94922d0e-20e5-11f0-ac62-0242ac120002" + +# 测试数据 +test_data = { + "message": "1+1", + "re_chat": False, + "stream": True +} + +# 请求头 +headers = { + "Content-Type": "application/json", + "User-Agent": "Apifox/1.0.0 (https://apifox.com)", + "Accept": "*/*", + "Host": "81.69.223.133:48329", + "Connection": "keep-alive" +} + +print(f"API URL: {API_URL}") +print(f"发送请求: {json.dumps(test_data, ensure_ascii=False)}") +print("等待响应...") + +start_time = time.time() +# 发送请求并获取流式响应 +response = requests.post( + url=API_URL, + json=test_data, + headers=headers, + stream=True # 启用流式传输 +) + +print(f"响应状态码: {response.status_code}") +print(f"收到初始响应时间: {datetime.now().strftime('%H:%M:%S.%f')[:-3]}") + +if response.status_code != 200 and response.status_code != 201: + print(f"错误: {response.status_code}, {response.text}") + sys.exit(1) + +# 处理流式响应 +print("\n----- 开始接收流式响应 -----\n") + +buffer = "" +last_time = start_time +response_count = 0 +full_content = "" # 用于收集完整内容 + +# 使用小的chunk_size,更好地展示流式效果 +for chunk in response.iter_content(chunk_size=1): + if chunk: + current_time = time.time() + time_diff = current_time - last_time + elapsed = current_time - start_time + + # 解码字节为字符串 + try: + chunk_str = chunk.decode('utf-8') + buffer += chunk_str + + # 检查是否有完整的数据行 + if '\n\n' in buffer: + lines = buffer.split('\n\n') + # 除了最后一行,其他都是完整的 + for line in lines[:-1]: + if line.strip(): + response_count += 1 + timestamp = datetime.now().strftime('%H:%M:%S.%f')[:-3] + + print(f"\n[{timestamp}] 响应 #{response_count} (距上次: {time_diff:.3f}s, 总计: {elapsed:.3f}s)") + print(f"{line}") + + # 如果想解析JSON内容 + if line.startswith('data: '): + try: + json_str = line[6:] # 去掉 "data: " 前缀 + data = json.loads(json_str) + + # 提取并累积内容 + if 'content' in data: + content = data.get('content', '') + full_content += content + + # 如果内容太长,只显示前50个字符 + if len(content) > 50: + content_display = content[:50] + "..." + else: + content_display = content + + print(f"内容片段: '{content_display}'") + print(f"是否结束: {data.get('is_end', False)}") + except json.JSONDecodeError: + pass + + # 保留最后一个可能不完整的行 + buffer = lines[-1] + + # 重置计时器以准确测量下一个数据包间隔 + last_time = current_time + except UnicodeDecodeError: + # 忽略解码错误,可能是部分Unicode字符 + pass + +# 处理可能的剩余数据 +if buffer.strip(): + timestamp = datetime.now().strftime('%H:%M:%S.%f')[:-3] + elapsed = time.time() - start_time + print(f"\n[{timestamp}] 最终响应 (总计: {elapsed:.3f}s)") + print(f"{buffer}") + + # 尝试处理最后一段数据 + if buffer.startswith('data: '): + try: + json_str = buffer[6:] # 去掉 "data: " 前缀 + data = json.loads(json_str) + if 'content' in data: + full_content += data.get('content', '') + except: + pass + +total_time = time.time() - start_time +print(f"\n----- 响应结束 -----") +print(f"总响应时间: {total_time:.3f}秒, 共接收 {response_count} 个数据包") +print(f"完整收集的内容: {full_content}") \ No newline at end of file diff --git a/test_stream_api.py b/test_stream_api.py new file mode 100644 index 0000000..268af66 --- /dev/null +++ b/test_stream_api.py @@ -0,0 +1,106 @@ +import requests +import json +import sys +import time +from datetime import datetime + +# 接口地址 - 根据curl命令更新 +API_URL = "http://127.0.0.1:8000/api/chat-history/" # 注意没有create路径 + +# 测试数据 +test_data = { + "question": "总结下", + "conversation_id": "10b34248-2625-434b-a493-6d43520c837a", + "dataset_id_list": ["8390ca43-6e63-4df9-b0b9-6cb20e1b38af"], + "stream": True +} + +# 请求头 - 添加认证令牌和其他头信息 +headers = { + "Content-Type": "application/json", + "Authorization": "Token 7831a86588bc08d025e4c9bd668de3b7940f7634", + "User-Agent": "Apifox/1.0.0 (https://apifox.com)", + "Accept": "*/*", + "Host": "127.0.0.1:8000", + "Connection": "keep-alive" +} + +print(f"API URL: {API_URL}") +print(f"发送请求: {json.dumps(test_data, ensure_ascii=False)}") +print("等待响应...") + +start_time = time.time() +# 发送请求并获取流式响应 +response = requests.post( + url=API_URL, + json=test_data, + headers=headers, + stream=True # 启用流式传输 +) + +print(f"响应状态码: {response.status_code}") +print(f"收到初始响应时间: {datetime.now().strftime('%H:%M:%S.%f')[:-3]}") + +if response.status_code != 200 and response.status_code != 201: + print(f"错误: {response.status_code}, {response.text}") + sys.exit(1) + +# 处理流式响应 +print("\n----- 开始接收流式响应 -----\n") + +buffer = "" +last_time = start_time +response_count = 0 + +for chunk in response.iter_content(chunk_size=1024): + if chunk: + current_time = time.time() + time_diff = current_time - last_time + last_time = current_time + elapsed = current_time - start_time + + # 解码字节为字符串 + chunk_str = chunk.decode('utf-8') + buffer += chunk_str + + # 检查是否有完整的数据行 + if '\n\n' in buffer: + lines = buffer.split('\n\n') + # 除了最后一行,其他都是完整的 + for line in lines[:-1]: + if line.strip(): + response_count += 1 + timestamp = datetime.now().strftime('%H:%M:%S.%f')[:-3] + + print(f"\n[{timestamp}] 响应 #{response_count} (距上次: {time_diff:.3f}s, 总计: {elapsed:.3f}s)") + print(f"{line}") + + # 如果想解析JSON内容,可以取消下面的注释 + if line.startswith('data: '): + try: + json_str = line[6:] # 去掉 "data: " 前缀 + data = json.loads(json_str) + if data.get('content'): + content = data.get('content') + # 如果内容太长,只显示前30个字符 + if len(content) > 30: + content = content[:30] + "..." + print(f"内容片段: {content}") + except json.JSONDecodeError: + pass + + # 保留最后一个可能不完整的行 + buffer = lines[-1] + # 重置计时器 + last_time = time.time() + +# 处理可能的剩余数据 +if buffer.strip(): + timestamp = datetime.now().strftime('%H:%M:%S.%f')[:-3] + elapsed = time.time() - start_time + print(f"\n[{timestamp}] 最终响应 (总计: {elapsed:.3f}s)") + print(f"{buffer}") + +total_time = time.time() - start_time +print(f"\n----- 响应结束 -----") +print(f"总响应时间: {total_time:.3f}秒, 共接收 {response_count} 个数据包") \ No newline at end of file diff --git a/test_stream_response.py b/test_stream_response.py new file mode 100644 index 0000000..8a30c78 --- /dev/null +++ b/test_stream_response.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +"""流式响应测试脚本""" + +import requests +import json +import time +from datetime import datetime +import argparse +import logging.config +from test_config import API_BASE_URL, AUTH_TOKEN, TEST_CASES, LOG_CONFIG, HEADERS + +# 配置日志 +logging.config.dictConfig(LOG_CONFIG) +logger = logging.getLogger('stream_test') + +class StreamResponseTester: + def __init__(self, base_url, headers): + self.base_url = base_url + self.headers = headers + + def run_test(self, test_case): + """运行单个测试用例""" + logger.info(f"\n开始测试: {test_case['name']}") + + # 构建请求数据 + test_data = { + "question": test_case["question"], + "conversation_id": test_case["conversation_id"], + "dataset_id_list": [test_case["dataset_id"]], + "stream": True + } + + start_time = time.time() + response_count = 0 + content_length = 0 + full_content = "" + + try: + # 发送请求 + url = f"{self.base_url}/api/chat-history/" + logger.info(f"请求URL: {url}") + logger.info(f"请求数据: {json.dumps(test_data, ensure_ascii=False)}") + + response = requests.post( + url=url, + json=test_data, + headers=self.headers, + stream=True + ) + + # 检查响应状态 + if response.status_code != 200: + if test_case.get('expected_error', False): + logger.info("测试通过:预期的错误响应") + return True + logger.error(f"请求失败: {response.status_code}, {response.text}") + return False + + # 处理流式响应 + buffer = "" + last_time = start_time + + for chunk in response.iter_content(chunk_size=1): + if chunk: + current_time = time.time() + chunk_time = current_time - last_time + + try: + chunk_str = chunk.decode('utf-8') + buffer += chunk_str + + if '\n\n' in buffer: + lines = buffer.split('\n\n') + for line in lines[:-1]: + if line.startswith('data: '): + response_count += 1 + + try: + data = json.loads(line[6:]) + if 'data' in data and 'content' in data['data']: + content = data['data']['content'] + prev_length = content_length + content_length += len(content) + full_content += content + + # 记录响应信息 + logger.debug( + f"响应 #{response_count}: " + f"+{content_length - prev_length} 字符, " + f"间隔: {chunk_time:.3f}s" + ) + + # 检查是否结束 + if data['data'].get('is_end', False): + total_time = time.time() - start_time + logger.info(f"\n测试结果:") + logger.info(f"总响应时间: {total_time:.3f}秒") + logger.info(f"数据包数量: {response_count}") + logger.info(f"内容长度: {content_length} 字符") + logger.info(f"完整内容: {full_content}") + + # 检查响应时间是否符合预期 + if 'expected_response_time' in test_case: + if total_time <= test_case['expected_response_time']: + logger.info("响应时间符合预期") + else: + logger.warning( + f"响应时间超出预期: " + f"{total_time:.3f}s > {test_case['expected_response_time']}s" + ) + + return True + + except json.JSONDecodeError as e: + logger.error(f"JSON解析错误: {e}") + if not test_case.get('expected_error', False): + return False + + buffer = lines[-1] + last_time = current_time + + except UnicodeDecodeError: + logger.debug("解码错误,跳过") + continue + + return True + + except Exception as e: + logger.error(f"测试执行错误: {str(e)}") + return False + +def main(): + parser = argparse.ArgumentParser(description='流式响应测试工具') + parser.add_argument('--test-case', type=int, help='指定要运行的测试用例索引') + args = parser.parse_args() + + # 创建测试器实例 + tester = StreamResponseTester(API_BASE_URL, HEADERS) + + if args.test_case is not None: + # 运行指定的测试用例 + if 0 <= args.test_case < len(TEST_CASES): + test_case = TEST_CASES[args.test_case] + success = tester.run_test(test_case) + logger.info(f"\n测试用例 {args.test_case} {'通过' if success else '失败'}") + else: + logger.error(f"无效的测试用例索引: {args.test_case}") + else: + # 运行所有测试用例 + total_cases = len(TEST_CASES) + passed_cases = 0 + + for i, test_case in enumerate(TEST_CASES): + logger.info(f"\n运行测试用例 {i+1}/{total_cases}") + if tester.run_test(test_case): + passed_cases += 1 + + logger.info(f"\n测试完成: {passed_cases}/{total_cases} 个测试用例通过") + +if __name__ == '__main__': + main() diff --git a/test_websocket_stream.py b/test_websocket_stream.py new file mode 100644 index 0000000..f9d1593 --- /dev/null +++ b/test_websocket_stream.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +WebSocket流式输出测试脚本 +""" +import websocket +import json +import sys +import time +import uuid +from datetime import datetime +import threading +import ssl +import argparse + +# 测试配置 +WS_URL = "ws://127.0.0.1:8000/ws/chat/stream/" # WebSocket URL +TOKEN = "7831a86588bc08d025e4c9bd668de3b7940f7634" # 替换为你的实际认证令牌 + +# 测试数据 +test_data = { + "question": "什么是流式输出?", + "conversation_id": str(uuid.uuid4()), # 随机生成一个会话ID + "dataset_id_list": ["8390ca43-6e63-4df9-b0b9-6cb20e1b38af"] # 替换为实际的知识库ID +} + +# 全局变量 +response_count = 0 +start_time = None +full_content = "" +is_connected = False + + +def on_message(ws, message): + """ + 处理接收到的WebSocket消息 + """ + global response_count, full_content + + try: + # 解析JSON响应 + response_count += 1 + data = json.loads(message) + + # 获取当前时间和距开始的时间 + current_time = time.time() + elapsed = current_time - start_time + timestamp = datetime.now().strftime('%H:%M:%S.%f')[:-3] + + # 打印基本信息 + print(f"\n[{timestamp}] 响应 #{response_count} (总计: {elapsed:.3f}s)") + + # 检查消息类型 + msg_type = data.get('message', '') + if msg_type == '开始流式传输': + print("=== 开始接收流式内容 ===") + elif msg_type == 'partial': + # 显示部分内容 + if 'data' in data and 'content' in data['data']: + content = data['data']['content'] + full_content += content + # 如果内容太长,只显示前50个字符 + display_content = content[:50] + "..." if len(content) > 50 else content + print(f"部分内容: {display_content}") + elif msg_type == '完成': + # 显示完整信息 + if 'data' in data: + if 'title' in data['data']: + print(f"标题: {data['data']['title']}") + if 'content' in data['data']: + print(f"完整内容长度: {len(data['data']['content'])} 字符") + print("=== 流式传输完成 ===") + + # 如果是错误消息 + if data.get('code') == 500: + print(f"错误: {data.get('message')}") + ws.close() + + except json.JSONDecodeError as e: + print(f"JSON解析错误: {str(e)}") + except Exception as e: + print(f"处理消息时出错: {str(e)}") + + +def on_error(ws, error): + """处理WebSocket错误""" + print(f"发生错误: {str(error)}") + + +def on_close(ws, close_status_code, close_msg): + """处理WebSocket连接关闭""" + global is_connected + is_connected = False + total_time = time.time() - start_time + print(f"\n===== 连接关闭 =====") + print(f"状态码: {close_status_code}, 消息: {close_msg}") + print(f"总响应时间: {total_time:.3f}秒, 共接收 {response_count} 个数据包") + print(f"接收到的完整内容长度: {len(full_content)} 字符") + + +def on_open(ws): + """处理WebSocket连接成功""" + global start_time, is_connected + is_connected = True + print("WebSocket连接已建立") + print(f"发送测试数据: {json.dumps(test_data, ensure_ascii=False)}") + + # 记录开始时间 + start_time = time.time() + + # 发送测试数据 + ws.send(json.dumps(test_data)) + print("数据已发送,等待响应...") + + +def main(): + """主函数""" + parser = argparse.ArgumentParser(description='WebSocket流式输出测试工具') + parser.add_argument('--url', type=str, default=WS_URL, help='WebSocket URL') + parser.add_argument('--token', type=str, default=TOKEN, help='认证令牌') + parser.add_argument('--question', type=str, default=test_data['question'], help='要发送的问题') + args = parser.parse_args() + + # 更新测试数据 + url = f"{args.url}?token={args.token}" + test_data['question'] = args.question + + print(f"连接到: {url}") + + # 设置更详细的日志级别(可选) + # websocket.enableTrace(True) + + # 创建WebSocket连接 + ws = websocket.WebSocketApp( + url, + on_open=on_open, + on_message=on_message, + on_error=on_error, + on_close=on_close + ) + + # 设置运行超时(可选) + # 如果需要SSL连接 + # ws.run_forever(sslopt={"cert_reqs": ssl.CERT_NONE}) + + # 启动WebSocket连接 + ws.run_forever() + + # 等待一小段时间以确保所有消息都被处理 + time.sleep(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/user_management/consumers.py b/user_management/consumers.py index c5b82a6..e0af37e 100644 --- a/user_management/consumers.py +++ b/user_management/consumers.py @@ -5,6 +5,12 @@ from channels.exceptions import StopConsumer import logging from rest_framework.authtoken.models import Token from urllib.parse import parse_qs +from .models import ChatHistory, KnowledgeBase +import aiohttp +import asyncio +from django.conf import settings +import uuid +import traceback logger = logging.getLogger(__name__) @@ -69,3 +75,816 @@ class NotificationConsumer(AsyncWebsocketConsumer): logger.info(f"已发送通知给用户 {self.user.username}") except Exception as e: logger.error(f"发送通知消息时发生错误: {str(e)}") + +class ChatConsumer(AsyncWebsocketConsumer): + async def connect(self): + """建立 WebSocket 连接""" + try: + # 从URL参数中获取token + query_string = self.scope.get('query_string', b'').decode() + query_params = parse_qs(query_string) + token_key = query_params.get('token', [''])[0] + + if not token_key: + logger.warning("WebSocket连接尝试,但没有提供token") + await self.close() + return + + # 验证token + self.user = await self.get_user_from_token(token_key) + if not self.user: + logger.warning(f"WebSocket连接尝试,但token无效: {token_key}") + await self.close() + return + + # 将用户信息存储在scope中 + self.scope["user"] = self.user + await self.accept() + logger.info(f"用户 {self.user.username} WebSocket连接成功") + + except Exception as e: + logger.error(f"WebSocket连接错误: {str(e)}") + await self.close() + + @database_sync_to_async + def get_user_from_token(self, token_key): + try: + token = Token.objects.select_related('user').get(key=token_key) + return token.user + except Token.DoesNotExist: + return None + + async def disconnect(self, close_code): + """关闭 WebSocket 连接""" + pass + + async def receive(self, text_data): + """接收消息并处理""" + try: + data = json.loads(text_data) + + # 验证必要字段 + if 'question' not in data or 'conversation_id' not in data: + await self.send_error("缺少必要字段") + return + + # 创建问题记录 + question_record = await self.create_question_record(data) + if not question_record: + return + + # 开始流式处理 + await self.stream_answer(question_record, data) + + except Exception as e: + logger.error(f"处理消息时出错: {str(e)}") + await self.send_error(f"处理消息时出错: {str(e)}") + + @database_sync_to_async + def _create_question_record_sync(self, data): + """同步创建问题记录""" + try: + # 获取会话历史记录 + conversation_id = data['conversation_id'] + existing_records = ChatHistory.objects.filter( + conversation_id=conversation_id + ).order_by('created_at') + + # 获取或创建元数据 + if existing_records.exists(): + first_record = existing_records.first() + metadata = first_record.metadata or {} + dataset_ids = metadata.get('dataset_id_list', []) + knowledge_bases = [] + + # 验证知识库权限 + for kb_id in dataset_ids: + try: + kb = KnowledgeBase.objects.get(id=kb_id) + if not self.check_knowledge_base_permission(kb, self.scope["user"], 'read'): + raise Exception(f'无权访问知识库: {kb.name}') + knowledge_bases.append(kb) + except KnowledgeBase.DoesNotExist: + raise Exception(f'知识库不存在: {kb_id}') + else: + # 新会话处理 + dataset_ids = data.get('dataset_id_list', []) + if not dataset_ids: + raise Exception('新会话需要提供知识库ID') + + knowledge_bases = [] + for kb_id in dataset_ids: + kb = KnowledgeBase.objects.get(id=kb_id) + if not self.check_knowledge_base_permission(kb, self.scope["user"], 'read'): + raise Exception(f'无权访问知识库: {kb.name}') + knowledge_bases.append(kb) + + metadata = { + 'model_id': data.get('model_id', '7a214d0e-e65e-11ef-9f4a-0242ac120006'), + 'dataset_id_list': [str(kb.id) for kb in knowledge_bases], + 'dataset_external_id_list': [str(kb.external_id) for kb in knowledge_bases if kb.external_id], + 'dataset_names': [kb.name for kb in knowledge_bases] + } + + # 创建问题记录 + return ChatHistory.objects.create( + user=self.scope["user"], + knowledge_base=knowledge_bases[0], + conversation_id=conversation_id, + title=data.get('title', 'New chat'), + role='user', + content=data['question'], + metadata=metadata + ) + + except Exception as e: + logger.error(f"创建问题记录失败: {str(e)}") + return None, str(e) + + async def create_question_record(self, data): + """异步创建问题记录""" + try: + result = await self._create_question_record_sync(data) + if isinstance(result, tuple): + _, error_message = result + await self.send_error(error_message) + return None + return result + except Exception as e: + await self.send_error(str(e)) + return None + + def check_knowledge_base_permission(self, kb, user, permission_type): + """检查知识库权限""" + # 实现权限检查逻辑 + return True # 临时返回 True,需要根据实际情况实现 + + async def stream_answer(self, question_record, data): + """流式处理回答""" + try: + # 创建 AI 回答记录 + answer_record = await database_sync_to_async(ChatHistory.objects.create)( + user=self.scope["user"], + knowledge_base=question_record.knowledge_base, + conversation_id=str(question_record.conversation_id), + title=question_record.title, + parent_id=str(question_record.id), + role='assistant', + content="", + metadata=question_record.metadata + ) + + # 发送初始响应 + await self.send_json({ + 'code': 200, + 'message': '开始流式传输', + 'data': { + 'id': str(answer_record.id), + 'conversation_id': str(question_record.conversation_id), + 'content': '', + 'is_end': False + } + }) + + # 调用外部 API 获取流式响应 + async with aiohttp.ClientSession() as session: + # 创建聊天会话 + chat_response = await session.post( + f"{settings.API_BASE_URL}/api/application/chat/open", + json={ + "id": "d5d11efa-ea9a-11ef-9933-0242ac120006", + "model_id": question_record.metadata.get('model_id'), + "dataset_id_list": question_record.metadata.get('dataset_external_id_list', []), + "multiple_rounds_dialogue": False, + "dataset_setting": { + "top_n": 10, + "similarity": "0.3", + "max_paragraph_char_number": 10000, + "search_mode": "blend", + "no_references_setting": { + "value": "{question}", + "status": "ai_questioning" + } + }, + "model_setting": { + "prompt": "**相关文档内容**:{data} **回答要求**:如果相关文档内容中没有可用信息,请回答\"没有在知识库中查找到相关信息,建议咨询相关技术支持或参考官方文档进行操作\"。请根据相关文档内容回答用户问题。不要输出与用户问题无关的内容。请使用中文回答客户问题。**用户问题**:{question}" + }, + "problem_optimization": False + } + ) + + chat_data = await chat_response.json() + if chat_data.get('code') != 200: + raise Exception(f"创建聊天会话失败: {chat_data}") + + chat_id = chat_data['data'] + + # 建立流式连接 + async with session.post( + f"{settings.API_BASE_URL}/api/application/chat_message/{chat_id}", + json={"message": data['question'], "re_chat": False, "stream": True}, + headers={"Content-Type": "application/json"} + ) as response: + full_content = "" + buffer = "" + + async for chunk in response.content.iter_any(): + chunk_str = chunk.decode('utf-8') + buffer += chunk_str + + while '\n\n' in buffer: + parts = buffer.split('\n\n', 1) + line = parts[0] + buffer = parts[1] + + if line.startswith('data: '): + try: + json_str = line[6:] + chunk_data = json.loads(json_str) + + if 'content' in chunk_data: + content_part = chunk_data['content'] + full_content += content_part + + await self.send_json({ + 'code': 200, + 'message': 'partial', + 'data': { + 'id': str(answer_record.id), + 'conversation_id': str(question_record.conversation_id), + 'content': content_part, + 'is_end': chunk_data.get('is_end', False) + } + }) + + if chunk_data.get('is_end', False): + # 保存完整内容 + answer_record.content = full_content.strip() + await database_sync_to_async(answer_record.save)() + + # 生成或获取标题 + title = await self.get_or_generate_title( + question_record.conversation_id, + data['question'], + full_content.strip() + ) + + # 发送最终响应 + await self.send_json({ + 'code': 200, + 'message': '完成', + 'data': { + 'id': str(answer_record.id), + 'conversation_id': str(question_record.conversation_id), + 'title': title, + 'dataset_id_list': question_record.metadata.get('dataset_id_list', []), + 'dataset_names': question_record.metadata.get('dataset_names', []), + 'role': 'assistant', + 'content': full_content.strip(), + 'created_at': answer_record.created_at.strftime('%Y-%m-%d %H:%M:%S'), + 'is_end': True + } + }) + return + + except json.JSONDecodeError as e: + logger.error(f"JSON解析错误: {e}, 数据: {line}") + continue + + except Exception as e: + logger.error(f"流式处理出错: {str(e)}") + await self.send_error(str(e)) + + # 保存已收集的内容 + if 'full_content' in locals() and full_content: + try: + answer_record.content = full_content.strip() + await database_sync_to_async(answer_record.save)() + except Exception as save_error: + logger.error(f"保存部分内容失败: {str(save_error)}") + + @database_sync_to_async + def get_or_generate_title(self, conversation_id, question, answer): + """获取或生成对话标题""" + try: + # 先检查是否已有标题 + current_title = ChatHistory.objects.filter( + conversation_id=str(conversation_id) + ).exclude( + title__in=["New chat", "新对话", ""] + ).values_list('title', flat=True).first() + + if current_title: + return current_title + + # 如果没有标题,生成新标题 + # 这里需要实现标题生成的逻辑 + generated_title = "新对话" # 临时使用默认标题 + + # 更新所有相关记录的标题 + ChatHistory.objects.filter( + conversation_id=str(conversation_id) + ).update(title=generated_title) + + return generated_title + + except Exception as e: + logger.error(f"获取或生成标题失败: {str(e)}") + return "新对话" + + async def send_json(self, content): + """发送 JSON 格式的消息""" + await self.send(text_data=json.dumps(content)) + + async def send_error(self, message): + """发送错误消息""" + await self.send_json({ + 'code': 500, + 'message': message, + 'data': {'is_end': True} + }) + +class ChatStreamConsumer(AsyncWebsocketConsumer): + async def connect(self): + """建立WebSocket连接""" + try: + # 从URL参数中获取token + query_string = self.scope.get('query_string', b'').decode() + query_params = parse_qs(query_string) + token_key = query_params.get('token', [''])[0] + + if not token_key: + logger.warning("WebSocket连接尝试,但没有提供token") + await self.close() + return + + # 验证token + self.user = await self.get_user_from_token(token_key) + if not self.user: + logger.warning(f"WebSocket连接尝试,但token无效: {token_key}") + await self.close() + return + + # 将用户信息存储在scope中 + self.scope["user"] = self.user + await self.accept() + logger.info(f"用户 {self.user.username} 流式输出WebSocket连接成功") + + except Exception as e: + logger.error(f"WebSocket连接错误: {str(e)}") + await self.close() + + @database_sync_to_async + def get_user_from_token(self, token_key): + try: + token = Token.objects.select_related('user').get(key=token_key) + return token.user + except Token.DoesNotExist: + return None + + async def disconnect(self, close_code): + """关闭WebSocket连接""" + logger.info(f"用户 {self.user.username if hasattr(self, 'user') else 'unknown'} WebSocket连接断开,代码: {close_code}") + + async def receive(self, text_data): + """接收消息并处理""" + try: + data = json.loads(text_data) + + # 检查必填字段 + if 'question' not in data: + await self.send_error("缺少必填字段: question") + return + + if 'conversation_id' not in data: + await self.send_error("缺少必填字段: conversation_id") + return + + # 处理新会话或现有会话 + await self.process_chat_request(data) + + except Exception as e: + logger.error(f"处理消息时出错: {str(e)}") + logger.error(traceback.format_exc()) + await self.send_error(f"处理消息时出错: {str(e)}") + + async def process_chat_request(self, data): + """处理聊天请求""" + try: + conversation_id = data['conversation_id'] + question = data['question'] + + # 获取会话信息和知识库 + session_info = await self.get_session_info(data) + if not session_info: + return + + knowledge_bases, metadata, dataset_external_id_list = session_info + + # 创建问题记录 + question_record = await self.create_question_record( + conversation_id, + question, + knowledge_bases, + metadata + ) + + if not question_record: + return + + # 创建AI回答记录 + answer_record = await self.create_answer_record( + conversation_id, + question_record, + knowledge_bases, + metadata + ) + + # 发送初始响应 + await self.send_json({ + 'code': 200, + 'message': '开始流式传输', + 'data': { + 'id': str(answer_record.id), + 'conversation_id': str(conversation_id), + 'content': '', + 'is_end': False + } + }) + + # 调用外部API获取流式响应 + await self.stream_from_external_api( + conversation_id, + question, + dataset_external_id_list, + answer_record, + metadata, + knowledge_bases + ) + + except Exception as e: + logger.error(f"处理聊天请求时出错: {str(e)}") + logger.error(traceback.format_exc()) + await self.send_error(f"处理聊天请求时出错: {str(e)}") + + @database_sync_to_async + def get_session_info(self, data): + """获取会话信息和知识库""" + try: + conversation_id = data['conversation_id'] + + # 查找该会话ID下的历史记录 + existing_records = ChatHistory.objects.filter( + conversation_id=conversation_id + ).order_by('created_at') + + # 如果有历史记录,使用第一条记录的metadata + if existing_records.exists(): + first_record = existing_records.first() + metadata = first_record.metadata or {} + + # 获取知识库信息 + dataset_ids = metadata.get('dataset_id_list', []) + external_id_list = metadata.get('dataset_external_id_list', []) + + if not dataset_ids: + logger.error('找不到会话关联的知识库信息') + return None + + # 验证知识库是否存在且用户有权限 + knowledge_bases = [] + for kb_id in dataset_ids: + try: + kb = KnowledgeBase.objects.get(id=kb_id) + if not self.check_knowledge_base_permission(kb, self.scope["user"], 'read'): + logger.error(f'无权访问知识库: {kb.name}') + return None + knowledge_bases.append(kb) + except KnowledgeBase.DoesNotExist: + logger.error(f'知识库不存在: {kb_id}') + return None + + if not external_id_list or not knowledge_bases: + logger.error('会话关联的知识库信息不完整') + return None + + return knowledge_bases, metadata, external_id_list + + else: + # 如果是新会话的第一条记录,需要提供知识库ID + dataset_ids = [] + if 'dataset_id' in data: + dataset_ids.append(str(data['dataset_id'])) + elif 'dataset_id_list' in data and isinstance(data['dataset_id_list'], (list, str)): + if isinstance(data['dataset_id_list'], str): + try: + dataset_list = json.loads(data['dataset_id_list']) + if isinstance(dataset_list, list): + dataset_ids = [str(id) for id in dataset_list] + else: + dataset_ids = [str(data['dataset_id_list'])] + except json.JSONDecodeError: + dataset_ids = [str(data['dataset_id_list'])] + else: + dataset_ids = [str(id) for id in data['dataset_id_list']] + + if not dataset_ids: + logger.error('新会话需要提供知识库ID') + return None + + # 验证所有知识库并收集external_ids + external_id_list = [] + knowledge_bases = [] + + for kb_id in dataset_ids: + try: + knowledge_base = KnowledgeBase.objects.filter(id=kb_id).first() + if not knowledge_base: + logger.error(f'知识库不存在: {kb_id}') + return None + + knowledge_bases.append(knowledge_base) + + # 使用统一的权限检查方法 + if not self.check_knowledge_base_permission(knowledge_base, self.scope["user"], 'read'): + logger.error(f'无权访问知识库: {knowledge_base.name}') + return None + + # 添加知识库的external_id到列表 + if knowledge_base.external_id: + external_id_list.append(str(knowledge_base.external_id)) + else: + logger.warning(f"知识库 {knowledge_base.id} ({knowledge_base.name}) 没有external_id") + + except Exception as e: + logger.error(f"处理知识库ID出错: {str(e)}") + return None + + if not external_id_list: + logger.error('没有有效的知识库external_id') + return None + + # 创建metadata + metadata = { + 'model_id': data.get('model_id', '7a214d0e-e65e-11ef-9f4a-0242ac120006'), + 'dataset_id_list': [str(id) for id in dataset_ids], + 'dataset_external_id_list': [str(id) for id in external_id_list], + 'dataset_names': [kb.name for kb in knowledge_bases] + } + + return knowledge_bases, metadata, external_id_list + + except Exception as e: + logger.error(f"获取会话信息时出错: {str(e)}") + return None + + def check_knowledge_base_permission(self, kb, user, permission_type): + """检查知识库权限""" + # 实现权限检查逻辑 + return True # 临时返回 True,需要根据实际情况实现 + + @database_sync_to_async + def create_question_record(self, conversation_id, question, knowledge_bases, metadata): + """创建问题记录""" + try: + title = metadata.get('title', 'New chat') + + # 创建用户问题记录 + return ChatHistory.objects.create( + user=self.scope["user"], + knowledge_base=knowledge_bases[0], # 使用第一个知识库作为主知识库 + conversation_id=str(conversation_id), + title=title, + role='user', + content=question, + metadata=metadata + ) + except Exception as e: + logger.error(f"创建问题记录时出错: {str(e)}") + return None + + @database_sync_to_async + def create_answer_record(self, conversation_id, question_record, knowledge_bases, metadata): + """创建AI回答记录""" + try: + return ChatHistory.objects.create( + user=self.scope["user"], + knowledge_base=knowledge_bases[0], + conversation_id=str(conversation_id), + title=question_record.title, + parent_id=str(question_record.id), + role='assistant', + content="", # 初始内容为空 + metadata=metadata + ) + except Exception as e: + logger.error(f"创建回答记录时出错: {str(e)}") + return None + + async def stream_from_external_api(self, conversation_id, question, dataset_external_id_list, answer_record, metadata, knowledge_bases): + """从外部API获取流式响应""" + try: + # 确保所有ID都是字符串 + dataset_external_ids = [str(id) if isinstance(id, uuid.UUID) else id for id in dataset_external_id_list] + + # 获取标题 + title = answer_record.title or 'New chat' + + # 异步收集完整内容,用于最后保存 + full_content = "" + + # 使用aiohttp进行异步HTTP请求 + async with aiohttp.ClientSession() as session: + # 第一步: 创建聊天会话 + async with session.post( + f"{settings.API_BASE_URL}/api/application/chat/open", + json={ + "id": "d5d11efa-ea9a-11ef-9933-0242ac120006", + "model_id": metadata.get('model_id', '7a214d0e-e65e-11ef-9f4a-0242ac120006'), + "dataset_id_list": dataset_external_ids, + "multiple_rounds_dialogue": False, + "dataset_setting": { + "top_n": 10, "similarity": "0.3", + "max_paragraph_char_number": 10000, + "search_mode": "blend", + "no_references_setting": { + "value": "{question}", + "status": "ai_questioning" + } + }, + "model_setting": { + "prompt": "**相关文档内容**:{data} **回答要求**:如果相关文档内容中没有可用信息,请回答\"没有在知识库中查找到相关信息,建议咨询相关技术支持或参考官方文档进行操作\"。请根据相关文档内容回答用户问题。不要输出与用户问题无关的内容。请使用中文回答客户问题。**用户问题**:{question}" + }, + "problem_optimization": False + } + ) as chat_response: + + if chat_response.status != 200: + error_msg = f"外部API调用失败: {await chat_response.text()}" + logger.error(error_msg) + await self.send_error(error_msg) + return + + chat_data = await chat_response.json() + if chat_data.get('code') != 200 or not chat_data.get('data'): + error_msg = f"外部API返回错误: {chat_data}" + logger.error(error_msg) + await self.send_error(error_msg) + return + + chat_id = chat_data['data'] + logger.info(f"成功创建聊天会话, chat_id: {chat_id}") + + # 第二步: 建立流式连接 + message_url = f"{settings.API_BASE_URL}/api/application/chat_message/{chat_id}" + logger.info(f"开始流式请求: {message_url}") + + # 创建流式请求 + async with session.post( + url=message_url, + json={"message": question, "re_chat": False, "stream": True}, + headers={"Content-Type": "application/json"} + ) as message_request: + + if message_request.status != 200: + error_msg = f"外部API聊天消息调用失败: {message_request.status}, {await message_request.text()}" + logger.error(error_msg) + await self.send_error(error_msg) + return + + # 创建一个缓冲区以处理分段的数据 + buffer = "" + + # 读取并处理每个响应块 + logger.info("开始处理流式响应") + async for chunk in message_request.content.iter_any(): + chunk_str = chunk.decode('utf-8') + buffer += chunk_str + + # 检查是否有完整的数据行 + while '\n\n' in buffer: + parts = buffer.split('\n\n', 1) + line = parts[0] + buffer = parts[1] + + if line.startswith('data: '): + try: + # 提取JSON数据 + json_str = line[6:] # 去掉 "data: " 前缀 + data = json.loads(json_str) + + # 记录并处理部分响应 + if 'content' in data: + content_part = data['content'] + full_content += content_part + + # 发送部分内容 + await self.send_json({ + 'code': 200, + 'message': 'partial', + 'data': { + 'id': str(answer_record.id), + 'conversation_id': str(conversation_id), + 'content': content_part, + 'is_end': data.get('is_end', False) + } + }) + + # 处理结束标记 + if data.get('is_end', False): + logger.info("收到流式响应结束标记") + # 保存完整内容 + await self.update_answer_content(answer_record.id, full_content.strip()) + + # 处理标题 + title = await self.get_or_generate_title( + conversation_id, + question, + full_content.strip() + ) + + # 发送最终响应 + await self.send_json({ + 'code': 200, + 'message': '完成', + 'data': { + 'id': str(answer_record.id), + 'conversation_id': str(conversation_id), + 'title': title, + 'dataset_id_list': metadata.get('dataset_id_list', []), + 'dataset_names': metadata.get('dataset_names', []), + 'role': 'assistant', + 'content': full_content.strip(), + 'created_at': answer_record.created_at.strftime('%Y-%m-%d %H:%M:%S'), + 'is_end': True + } + }) + return + + except json.JSONDecodeError as e: + logger.error(f"JSON解析错误: {e}, 数据: {line}") + continue + + except Exception as e: + logger.error(f"流式处理出错: {str(e)}") + logger.error(traceback.format_exc()) + await self.send_error(str(e)) + + # 保存已收集的内容 + if 'full_content' in locals() and full_content: + try: + await self.update_answer_content(answer_record.id, full_content.strip()) + except Exception as save_error: + logger.error(f"保存部分内容失败: {str(save_error)}") + + @database_sync_to_async + def update_answer_content(self, answer_id, content): + """更新回答内容""" + try: + answer_record = ChatHistory.objects.get(id=answer_id) + answer_record.content = content + answer_record.save() + return True + except Exception as e: + logger.error(f"更新回答内容失败: {str(e)}") + return False + + @database_sync_to_async + def get_or_generate_title(self, conversation_id, question, answer): + """获取或生成对话标题""" + try: + # 先检查是否已有标题 + current_title = ChatHistory.objects.filter( + conversation_id=str(conversation_id) + ).exclude( + title__in=["New chat", "新对话", ""] + ).values_list('title', flat=True).first() + + if current_title: + return current_title + + # 简单的标题生成逻辑 (可替换为调用DeepSeek API生成标题) + generated_title = question[:20] + "..." if len(question) > 20 else question + + # 更新所有相关记录的标题 + ChatHistory.objects.filter( + conversation_id=str(conversation_id) + ).update(title=generated_title) + + return generated_title + + except Exception as e: + logger.error(f"获取或生成标题失败: {str(e)}") + return "新对话" + + async def send_json(self, content): + """发送JSON格式的消息""" + await self.send(text_data=json.dumps(content)) + + async def send_error(self, message): + """发送错误消息""" + await self.send_json({ + 'code': 500, + 'message': message, + 'data': {'is_end': True} + }) diff --git a/user_management/feishu_chat_views.py b/user_management/feishu_chat_views.py index e76c847..0c1c8bd 100644 --- a/user_management/feishu_chat_views.py +++ b/user_management/feishu_chat_views.py @@ -1,5 +1,9 @@ import logging import traceback +import os +import pandas as pd +from django.http import FileResponse, HttpResponse +from django.conf import settings from rest_framework import status from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated @@ -13,7 +17,8 @@ from user_management.gmail_integration import GmailIntegration from feishu.feishu_ai_chat import ( fetch_table_records, find_duplicate_email_creators, process_duplicate_emails, auto_chat_session, - check_goal_achieved + check_goal_achieved, export_feishu_creators_to_excel, + export_matching_emails_to_excel ) logger = logging.getLogger(__name__) @@ -125,11 +130,11 @@ def process_feishu_table(request): if not duplicate_emails: return Response( - {"message": "未发现重复邮箱"}, + {"message": "未找到与系统中已有creator匹配的邮箱"}, status=status.HTTP_200_OK ) - # 处理重复邮箱记录 + # 处理匹配的邮箱记录 results = process_duplicate_emails(duplicate_emails, goal_template) # 如果需要自动对话 @@ -224,6 +229,16 @@ def run_auto_chat(request): # 如果是强制发送模式 if force_send: try: + # 创建Gmail集成实例 + gmail_integration = GmailIntegration(request.user) + + # 检查Gmail服务是否已正确初始化 + if not hasattr(gmail_integration, 'gmail_service') or gmail_integration.gmail_service is None: + return Response( + {"error": "Gmail服务未正确初始化,请检查Gmail API配置"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + # 获取知识库映射 mapping = GmailTalentMapping.objects.filter( user=request.user, @@ -428,6 +443,269 @@ def check_goal_status(request): except Exception as e: logger.error(f"检查目标状态时出错: {str(e)}") logger.error(traceback.format_exc()) + return Response( + {"error": str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def export_creators_data(request): + """ + 导出匹配的FeishuCreator数据到Excel文件 + + 请求参数: + table_id: 表格ID + view_id: 视图ID + app_token: 飞书应用TOKEN (可选) + access_token: 用户访问令牌 (可选) + app_id: 应用ID (可选) + app_secret: 应用密钥 (可选) + export_type: 导出类型,'creators'或'feishu',默认为'creators' + format: 格式,支持'excel'或'csv',默认为'excel' + """ + try: + # 检查用户权限 - 只允许组长使用 + if request.user.role != 'leader': + return Response( + {"error": "只有组长角色的用户可以使用此功能"}, + status=status.HTTP_403_FORBIDDEN + ) + + # 获取参数 + table_id = request.data.get("table_id", "tbl3oikG3F8YYtVA") # 默认表格ID + view_id = request.data.get("view_id", "vewSOIsmxc") # 默认视图ID + app_token = request.data.get("app_token", "XYE6bMQUOaZ5y5svj4vcWohGnmg") + access_token = request.data.get("access_token", "u-fK0HvbXVte.G2xzYs5oxV6k1nHu1glvFgG00l0Ma24VD") + app_id = request.data.get("app_id", "cli_a5c97daacb9e500d") + app_secret = request.data.get("app_secret", "fdVeOCLXmuIHZVmSV0VbJh9wd0Kq1o5y") + export_type = request.data.get("export_type", "creators") # 导出类型:creators或feishu + export_format = request.data.get("format", "excel") # 导出格式:excel或csv + + if export_format not in ["excel", "csv"]: + return Response( + {"error": "当前支持的格式有: excel, csv"}, + status=status.HTTP_400_BAD_REQUEST + ) + + logger.info(f"导出飞书数据: table_id={table_id}, view_id={view_id}, type={export_type}, format={export_format}") + + # 从飞书表格获取记录 + records = fetch_table_records( + app_token, + table_id, + view_id, + access_token, + app_id, + app_secret + ) + + if not records: + logger.warning("未获取到任何记录,可能是表格ID或视图ID不正确,或无权限访问") + return Response( + {"message": "未获取到任何记录"}, + status=status.HTTP_404_NOT_FOUND + ) + + # 查找重复邮箱的创作者 + duplicate_emails = find_duplicate_email_creators(records) + + if not duplicate_emails: + return Response( + {"message": "未找到与系统中已有creator匹配的邮箱"}, + status=status.HTTP_404_NOT_FOUND + ) + + # 创建存储导出文件的目录 + export_dir = os.path.join(settings.MEDIA_ROOT, 'exports') + os.makedirs(export_dir, exist_ok=True) + + # 根据导出类型和格式选择输出文件名 + if export_type == "creators": + file_prefix = "feishu_creators" + else: + file_prefix = "feishu_data" + + if export_format == "excel": + file_ext = ".xlsx" + else: + file_ext = ".csv" + + output_filename = f"{file_prefix}_{request.user.id}{file_ext}" + output_path = os.path.join(export_dir, output_filename) + + # 根据导出类型选择导出函数 + if export_type == "creators": + # 导出FeishuCreator数据 + if export_format == "excel": + excel_path = export_feishu_creators_to_excel(duplicate_emails, output_path) + + if not excel_path: + return Response( + {"error": "导出FeishuCreator数据失败"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + else: + # CSV格式导出直接处理 + try: + # 获取所有匹配的邮箱 + emails = list(duplicate_emails.keys()) + + # 从数据库获取FeishuCreator记录 + from user_management.models import FeishuCreator + creators = FeishuCreator.objects.filter(email__in=emails) + + if not creators.exists(): + return Response( + {"error": "没有找到匹配的FeishuCreator记录"}, + status=status.HTTP_404_NOT_FOUND + ) + + # 创建数据列表 + data = [] + for creator in creators: + # 处理datetime字段,移除时区信息 + created_at = creator.created_at + if hasattr(created_at, 'tzinfo') and created_at.tzinfo is not None: + created_at = created_at.replace(tzinfo=None) + + updated_at = creator.updated_at + if hasattr(updated_at, 'tzinfo') and updated_at.tzinfo is not None: + updated_at = updated_at.replace(tzinfo=None) + + row = { + 'id': str(creator.id), + 'handle': creator.handle, + 'email': creator.email, + 'phone': creator.phone, + 'created_at': created_at, + 'updated_at': updated_at, + # 其他需要的字段... + } + data.append(row) + + # 创建DataFrame并导出到CSV + df = pd.DataFrame(data) + df.to_csv(output_path, index=False, encoding='utf-8-sig') # 使用BOM标记以支持中文 + excel_path = output_path + except Exception as e: + logger.error(f"导出CSV时出错: {str(e)}") + logger.error(traceback.format_exc()) + return Response( + {"error": f"导出CSV失败: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + elif export_type == "feishu": + # 导出飞书原始数据 + if export_format == "excel": + excel_path = export_matching_emails_to_excel(duplicate_emails, records, output_path) + + if not excel_path: + return Response( + {"error": "导出飞书数据失败"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + else: + # CSV格式导出 + try: + # 创建数据列表 + data = [] + for email, email_records in duplicate_emails.items(): + for record in email_records: + fields = record.fields + row = { + 'Email': email, + 'RecordID': record.record_id + } + + # 提取所有字段 + for field_name, field_value in fields.items(): + row[field_name] = extract_field_value(field_value) + + data.append(row) + + # 创建DataFrame并导出到CSV + df = pd.DataFrame(data) + df.to_csv(output_path, index=False, encoding='utf-8-sig') + excel_path = output_path + except Exception as e: + logger.error(f"导出CSV时出错: {str(e)}") + logger.error(traceback.format_exc()) + return Response( + {"error": f"导出CSV失败: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + else: + return Response( + {"error": f"不支持的导出类型: {export_type},可选值为'creators'或'feishu'"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 获取服务器域名,考虑各种情况 + domain = request.build_absolute_uri('/').rstrip('/') + # 如果是本地开发环境,使用127.0.0.1:8000 + if 'localhost' in domain or '127.0.0.1' in domain: + # 从请求头获取Host + host = request.META.get('HTTP_HOST', '127.0.0.1:8000') + if ':' not in host: + host = f"{host}:8000" # 添加默认端口 + domain = f"http://{host}" + + # 构建下载URL + file_url = f"{domain}/api/feishu/download/{output_filename}" + + return Response({ + "status": "success", + "message": f"成功导出{export_type}数据,格式为{export_format}", + "matched_emails": len(duplicate_emails), + "file_url": file_url, + "file_name": output_filename + }, status=status.HTTP_200_OK) + + except Exception as e: + logger.error(f"导出数据时出错: {str(e)}") + logger.error(traceback.format_exc()) + return Response( + {"error": str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def download_exported_file(request, filename): + """ + 下载已导出的Excel文件 + + URL参数: + filename: 文件名 + """ + try: + # 检查用户权限 - 只允许组长使用 + if request.user.role != 'leader': + return Response( + {"error": "只有组长角色的用户可以使用此功能"}, + status=status.HTTP_403_FORBIDDEN + ) + + # 构建文件路径 + file_path = os.path.join(settings.MEDIA_ROOT, 'exports', filename) + + # 检查文件是否存在 + if not os.path.exists(file_path): + return Response( + {"error": f"文件不存在: {filename}"}, + status=status.HTTP_404_NOT_FOUND + ) + + # 返回文件 + response = FileResponse(open(file_path, 'rb')) + response['Content-Disposition'] = f'attachment; filename="{filename}"' + return response + + except Exception as e: + logger.error(f"下载文件时出错: {str(e)}") + logger.error(traceback.format_exc()) return Response( {"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR diff --git a/user_management/gmail_account_views.py b/user_management/gmail_account_views.py new file mode 100644 index 0000000..addf4ac --- /dev/null +++ b/user_management/gmail_account_views.py @@ -0,0 +1,753 @@ +import logging +import traceback +from datetime import datetime, timedelta +from django.utils import timezone +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated, IsAdminUser +from rest_framework.response import Response +from django.conf import settings + +from .models import GmailCredential +from .gmail_integration import GmailIntegration, GmailServiceManager + +logger = logging.getLogger(__name__) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def list_gmail_accounts(request): + """列出用户的所有Gmail账户""" + try: + # 获取用户的所有Gmail凭证 + credentials = GmailCredential.objects.filter( + user=request.user + ).order_by('-is_default', '-updated_at') + + # 转换为简单的数据结构 + accounts = [] + for cred in credentials: + # 检查账户状态 + is_valid = False + try: + # 尝试使用API获取认证状态 + gmail_integration = GmailIntegration( + user=request.user, + gmail_credential_id=str(cred.id) + ) + is_valid = gmail_integration.authenticate() + except Exception: + pass + + # 检查监听状态 + watch_expired = True + if cred.watch_expiration: + watch_expired = cred.watch_expiration < timezone.now() + + accounts.append({ + 'id': str(cred.id), + 'name': cred.name, + 'gmail_email': cred.gmail_email or "未知", + 'is_default': cred.is_default, + 'is_active': cred.is_active, + 'is_valid': is_valid, # 凭证是否有效 + 'watch_expired': watch_expired, + 'watch_expiration': cred.watch_expiration.strftime('%Y-%m-%d %H:%M:%S') if cred.watch_expiration else None, + 'created_at': cred.created_at.strftime('%Y-%m-%d %H:%M:%S'), + 'updated_at': cred.updated_at.strftime('%Y-%m-%d %H:%M:%S') + }) + + return Response({ + 'code': 200, + 'message': '获取Gmail账户列表成功', + 'data': accounts + }) + 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) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def set_default_gmail_account(request): + """设置默认Gmail账户""" + try: + # 获取账户ID + account_id = request.data.get('account_id') + if not account_id: + return Response({ + 'code': 400, + 'message': '缺少账户ID参数', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 查找账户 + try: + credential = GmailCredential.objects.get( + id=account_id, + user=request.user + ) + except GmailCredential.DoesNotExist: + return Response({ + 'code': 404, + 'message': '找不到指定的Gmail账户', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) + + # 设置为默认账户 + credential.is_default = True + credential.save() # save方法会自动处理其他账户的默认状态 + + # 清理服务单例,确保下次使用时获取的是最新状态 + GmailServiceManager.clear_instance(request.user) + + return Response({ + 'code': 200, + 'message': f'已将{credential.gmail_email or credential.name}设为默认Gmail账户', + 'data': { + 'id': str(credential.id), + 'name': credential.name, + 'gmail_email': credential.gmail_email, + 'is_default': True + } + }) + 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) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def update_gmail_account(request): + """更新Gmail账户信息""" + try: + # 获取参数 + account_id = request.data.get('account_id') + name = request.data.get('name') + is_active = request.data.get('is_active') + + if not account_id: + return Response({ + 'code': 400, + 'message': '缺少账户ID参数', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 查找账户 + try: + credential = GmailCredential.objects.get( + id=account_id, + user=request.user + ) + except GmailCredential.DoesNotExist: + return Response({ + 'code': 404, + 'message': '找不到指定的Gmail账户', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) + + # 更新信息 + if name is not None: + credential.name = name + + if is_active is not None: + credential.is_active = is_active + + credential.save() + + # 清理服务单例 + GmailServiceManager.clear_instance(request.user, str(credential.id)) + + return Response({ + 'code': 200, + 'message': '更新Gmail账户成功', + 'data': { + 'id': str(credential.id), + 'name': credential.name, + 'gmail_email': credential.gmail_email, + 'is_active': credential.is_active, + 'is_default': credential.is_default + } + }) + 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) + + +@api_view(['DELETE']) +@permission_classes([IsAuthenticated]) +def delete_gmail_account(request, account_id): + """删除Gmail账户""" + try: + # 查找账户 + try: + credential = GmailCredential.objects.get( + id=account_id, + user=request.user + ) + except GmailCredential.DoesNotExist: + return Response({ + 'code': 404, + 'message': '找不到指定的Gmail账户', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) + + # 记录账户信息 + account_info = { + 'id': str(credential.id), + 'name': credential.name, + 'gmail_email': credential.gmail_email + } + + # 清理服务单例 + GmailServiceManager.clear_instance(request.user, str(credential.id)) + + # 删除账户 + credential.delete() + + # 如果删除了默认账户,设置新的默认账户 + default_exists = GmailCredential.objects.filter( + user=request.user, + is_default=True + ).exists() + + if not default_exists: + # 设置最新的账户为默认 + latest_credential = GmailCredential.objects.filter( + user=request.user + ).order_by('-updated_at').first() + + if latest_credential: + latest_credential.is_default = True + latest_credential.save() + + return Response({ + 'code': 200, + 'message': f'成功删除Gmail账户: {account_info["name"]}', + 'data': account_info + }) + 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) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def add_gmail_account(request): + """添加新的Gmail账户""" + try: + # 获取可选的代理设置 + use_proxy = request.data.get('use_proxy', True) + proxy_url = request.data.get('proxy_url', 'http://127.0.0.1:7890') + + # 获取账户名称 + name = request.data.get('name', '新Gmail账户') + + # 获取客户端密钥(支持JSON格式字符串或上传的文件) + client_secret_json = None + + # 检查是否有上传的JSON文件 + if 'client_secret_file' in request.FILES: + import json + file_content = request.FILES['client_secret_file'].read().decode('utf-8') + try: + client_secret_json = json.loads(file_content) + logger.info("成功从上传的JSON文件解析客户端密钥") + except json.JSONDecodeError: + return Response({ + 'code': 400, + 'message': '上传的JSON文件格式无效', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + else: + # 尝试从请求数据中获取JSON字符串 + client_secret_json = request.data.get('client_secret_json') + + if not client_secret_json: + return Response({ + 'code': 400, + 'message': '缺少客户端密钥参数,请提供client_secret_json或上传client_secret_file', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 验证用户是否已达到Gmail账户数量限制 + max_accounts_per_user = getattr(settings, 'MAX_GMAIL_ACCOUNTS_PER_USER', 5) + current_accounts_count = GmailCredential.objects.filter(user=request.user, is_active=True).count() + + if current_accounts_count >= max_accounts_per_user: + return Response({ + 'code': 400, + 'message': f'您最多只能绑定{max_accounts_per_user}个Gmail账户', + 'data': { + 'current_count': current_accounts_count, + 'max_allowed': max_accounts_per_user + } + }, status=status.HTTP_400_BAD_REQUEST) + + # 创建Gmail集成实例 + gmail_integration = GmailIntegration( + user=request.user, + client_secret_json=client_secret_json, + use_proxy=use_proxy, + proxy_url=proxy_url + ) + + # 开始认证流程 + try: + gmail_integration.authenticate() + # 认证会抛出包含认证URL的异常 + except Exception as e: + error_message = str(e) + + # 检查是否包含认证URL(正常的OAuth流程) + if "Please visit this URL" in error_message: + # 提取认证URL + auth_url = error_message.split("URL: ")[1].split(" ")[0] + + # 记录认证会话信息,方便handle_gmail_auth_code使用 + request.session['gmail_auth_pending'] = { + 'name': name, + 'use_proxy': use_proxy, + 'proxy_url': proxy_url, + # 不存储client_secret_json,应该已经保存在OAuth流程中 + } + + return Response({ + 'code': 202, # Accepted,需要进一步操作 + 'message': '需要Gmail授权', + 'data': { + 'auth_url': auth_url, + 'name': name + } + }) + elif "Token has been expired or revoked" in error_message: + return Response({ + 'code': 401, + 'message': 'OAuth令牌已过期或被撤销,请重新授权', + 'data': None + }, status=status.HTTP_401_UNAUTHORIZED) + else: + # 其他错误 + logger.error(f"Gmail认证过程中出现错误: {error_message}") + return Response({ + 'code': 500, + 'message': f'Gmail认证失败: {error_message}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + return Response({ + 'code': 200, + 'message': '添加Gmail账户成功', + 'data': None + }) + 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) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def handle_gmail_auth_code(request): + """处理Gmail授权回调码""" + try: + # 获取授权码 + auth_code = request.data.get('auth_code') + if not auth_code: + return Response({ + 'code': 400, + 'message': '缺少授权码参数', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 获取账户名称和其他参数,优先从会话中获取 + auth_pending = request.session.pop('gmail_auth_pending', {}) + name = request.data.get('name', auth_pending.get('name', '新Gmail账户')) + use_proxy = request.data.get('use_proxy', auth_pending.get('use_proxy', True)) + proxy_url = request.data.get('proxy_url', auth_pending.get('proxy_url', 'http://127.0.0.1:7890')) + + # 创建Gmail集成实例 + gmail_integration = GmailIntegration( + user=request.user, + use_proxy=use_proxy, + proxy_url=proxy_url + ) + + # 处理授权码 + try: + result = gmail_integration.handle_auth_code(auth_code) + + # 设置账户名称 + if gmail_integration.gmail_credential: + gmail_integration.gmail_credential.name = name + gmail_integration.gmail_credential.save() + + # 初始化监听 + try: + watch_result = gmail_integration.setup_watch() + logger.info(f"初始化Gmail监听成功: {watch_result}") + except Exception as watch_error: + logger.error(f"初始化Gmail监听失败: {str(watch_error)}") + + # 返回结果 + return Response({ + 'code': 200, + 'message': 'Gmail账户授权成功', + 'data': { + 'id': str(gmail_integration.gmail_credential.id) if gmail_integration.gmail_credential else None, + 'name': name, + 'gmail_email': gmail_integration.gmail_credential.gmail_email if gmail_integration.gmail_credential else None, + 'watch_result': watch_result if 'watch_result' in locals() else None + } + }) + except Exception as auth_error: + error_message = str(auth_error) + logger.error(f"处理Gmail授权码失败: {error_message}") + + if "invalid_grant" in error_message.lower(): + return Response({ + 'code': 400, + 'message': '授权码无效或已过期,请重新授权', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + return Response({ + 'code': 500, + 'message': f'处理Gmail授权码失败: {error_message}', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + 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) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def refresh_gmail_account_watch(request): + """刷新特定Gmail账户的监听""" + try: + # 获取账户ID + account_id = request.data.get('account_id') + if not account_id: + return Response({ + 'code': 400, + 'message': '缺少账户ID参数', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 查找账户 + try: + credential = GmailCredential.objects.get( + id=account_id, + user=request.user, + is_active=True + ) + except GmailCredential.DoesNotExist: + return Response({ + 'code': 404, + 'message': '找不到指定的Gmail账户', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) + + # 获取可选的代理设置 + use_proxy = request.data.get('use_proxy', True) + proxy_url = request.data.get('proxy_url', 'http://127.0.0.1:7890') + + # 创建Gmail集成实例 + gmail_integration = GmailIntegration( + user=request.user, + gmail_credential_id=str(credential.id), + use_proxy=use_proxy, + proxy_url=proxy_url + ) + + # 认证Gmail + if not gmail_integration.authenticate(): + return Response({ + 'code': 400, + 'message': 'Gmail认证失败', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 设置监听 + watch_result = gmail_integration.setup_watch() + + # 更新监听过期时间 + expiration = watch_result.get('expiration') + history_id = watch_result.get('historyId') + + if expiration: + # 转换为datetime对象 + expiration_time = datetime.fromtimestamp(int(expiration) / 1000) + credential.watch_expiration = expiration_time + + if history_id: + credential.last_history_id = history_id + + credential.save() + + return Response({ + 'code': 200, + 'message': '刷新Gmail监听成功', + 'data': { + 'account_id': str(credential.id), + 'name': credential.name, + 'gmail_email': credential.gmail_email, + 'expiration': credential.watch_expiration.strftime('%Y-%m-%d %H:%M:%S') if credential.watch_expiration else None, + 'history_id': credential.last_history_id + } + }) + 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) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def check_specific_gmail_auth(request): + """检查特定Gmail账户的认证状态""" + try: + # 获取可选的账户ID参数 + gmail_credential_id = request.query_params.get('account_id') + + # 如果提供了账户ID,检查特定账户 + if gmail_credential_id: + credential = GmailCredential.objects.filter( + id=gmail_credential_id, + user=request.user + ).first() + else: + # 否则检查默认账户 + credential = GmailCredential.objects.filter( + user=request.user, + is_active=True, + is_default=True + ).first() + + # 如果没有默认账户,检查最新的账户 + if not credential: + credential = GmailCredential.objects.filter( + user=request.user, + is_active=True + ).order_by('-updated_at').first() + + if not credential: + return Response({ + 'code': 404, + 'message': '未找到Gmail认证信息', + 'data': { + 'authenticated': False, + 'needs_setup': True + } + }) + + # 获取可选的代理设置 + use_proxy = request.query_params.get('use_proxy', 'true').lower() == 'true' + proxy_url = request.query_params.get('proxy_url', 'http://127.0.0.1:7890') + + # 创建Gmail集成实例 + gmail_integration = GmailIntegration( + user=request.user, + gmail_credential_id=str(credential.id), + use_proxy=use_proxy, + proxy_url=proxy_url + ) + + # 测试认证 + auth_valid = gmail_integration.authenticate() + + # 检查监听是否过期 + watch_expired = True + if credential.watch_expiration: + watch_expired = credential.watch_expiration < timezone.now() + + return Response({ + 'code': 200, + 'message': '认证信息获取成功', + 'data': { + 'authenticated': auth_valid, + 'needs_setup': not auth_valid, + 'account_id': str(credential.id), + 'name': credential.name, + 'gmail_email': credential.gmail_email, + 'is_default': credential.is_default, + 'watch_expired': watch_expired, + 'last_history_id': credential.last_history_id, + 'watch_expiration': credential.watch_expiration.strftime('%Y-%m-%d %H:%M:%S') if credential.watch_expiration else None + } + }) + 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) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def clear_gmail_cache(request): + """清除Gmail服务缓存,解决授权和监听问题""" + try: + # 获取参数 + gmail_email = request.data.get('gmail_email') + account_id = request.data.get('account_id') + + if not gmail_email and not account_id: + return Response({ + 'code': 400, + 'message': '需要提供gmail_email或account_id参数', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 根据不同参数清除缓存 + if account_id: + # 如果提供了account_id,清除特定账号的缓存 + try: + credential = GmailCredential.objects.get( + id=account_id, + user=request.user + ) + gmail_email = credential.gmail_email + # 清除特定账号的缓存 + GmailServiceManager.clear_instance(request.user, account_id) + logger.info(f"已清除用户 {request.user.email} 的Gmail账号 {account_id} 缓存") + except GmailCredential.DoesNotExist: + return Response({ + 'code': 404, + 'message': '找不到指定的Gmail账号', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) + else: + # 如果只提供了gmail_email,清除所有相关缓存 + cleared_count = GmailServiceManager.clear_all_instances_by_email(gmail_email) + logger.info(f"已清除Gmail邮箱 {gmail_email} 的 {cleared_count} 个缓存实例") + + # 查找与此邮箱关联的所有凭证 + credentials = GmailCredential.objects.filter( + gmail_email=gmail_email, + is_active=True + ).select_related('user') + + # 创建Gmail集成实例,测试认证并刷新监听 + success_refreshed = [] + failed_refreshed = [] + + for credential in credentials: + try: + # 创建Gmail集成实例 + integration = GmailIntegration( + user=credential.user, + gmail_credential_id=str(credential.id) + ) + + # 测试认证 + if integration.authenticate(): + # 刷新监听 + try: + watch_result = integration.setup_watch() + # 更新监听过期时间 + if 'expiration' in watch_result: + # 转换为datetime对象 + from datetime import datetime + expiration_time = datetime.fromtimestamp(int(watch_result['expiration']) / 1000) + credential.watch_expiration = expiration_time + credential.save() + + success_refreshed.append({ + 'id': str(credential.id), + 'gmail_email': credential.gmail_email, + 'user_id': str(credential.user.id), + 'user_email': credential.user.email + }) + except Exception as watch_error: + failed_refreshed.append({ + 'id': str(credential.id), + 'gmail_email': credential.gmail_email, + 'user_id': str(credential.user.id), + 'user_email': credential.user.email, + 'error': str(watch_error) + }) + else: + failed_refreshed.append({ + 'id': str(credential.id), + 'gmail_email': credential.gmail_email, + 'user_id': str(credential.user.id), + 'user_email': credential.user.email, + 'error': '认证失败' + }) + except Exception as e: + failed_refreshed.append({ + 'id': str(credential.id), + 'gmail_email': credential.gmail_email, + 'user_id': str(credential.user.id) if credential.user else None, + 'user_email': credential.user.email if credential.user else None, + 'error': str(e) + }) + + # 处理队列通知 + queue_result = None + if success_refreshed: + try: + # 处理与这些成功刷新的凭证相关的队列通知 + from .gmail_integration import GmailIntegration + for cred_info in success_refreshed: + user_obj = GmailCredential.objects.get(id=cred_info['id']).user + queue_result = GmailIntegration.process_queued_notifications(user=user_obj) + except Exception as queue_error: + logger.error(f"处理队列通知失败: {str(queue_error)}") + + return Response({ + 'code': 200, + 'message': '清除Gmail缓存成功', + 'data': { + 'gmail_email': gmail_email, + 'success_refreshed': success_refreshed, + 'failed_refreshed': failed_refreshed, + 'queue_processed': queue_result + } + }) + 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) \ No newline at end of file diff --git a/user_management/gmail_integration.py b/user_management/gmail_integration.py index f5a2efe..637e138 100644 --- a/user_management/gmail_integration.py +++ b/user_management/gmail_integration.py @@ -7,7 +7,7 @@ import logging import traceback # 确保导入traceback模块 from pathlib import Path import dateutil.parser as parser -from datetime import datetime +from datetime import datetime, timedelta from bs4 import BeautifulSoup from django.utils import timezone from django.conf import settings # 添加Django设置导入 @@ -19,6 +19,9 @@ import warnings import mimetypes import requests import django.db.utils +import time +from django.db.models import Q +import socket # 忽略oauth2client相关的所有警告 warnings.filterwarnings('ignore', message='file_cache is unavailable when using oauth2client >= 4.0.0 or google-auth') @@ -32,21 +35,56 @@ from oauth2client import file, client, tools from .models import GmailCredential, KnowledgeBase, KnowledgeBaseDocument, User, GmailTalentMapping, ChatHistory, GmailAttachment, UserProfile +# Gmail API调用超时设置(秒) +GMAIL_REQUEST_TIMEOUT = getattr(settings, 'GMAIL_REQUEST_TIMEOUT', 30) + logger = logging.getLogger(__name__) # Gmail服务单例管理器 class GmailServiceManager: - _instances = {} # 以用户ID为键存储Gmail服务实例 + _instances = {} # 以用户ID和Gmail凭证ID为键存储Gmail服务实例 @classmethod - def get_instance(cls, user): - """获取用户的Gmail服务实例,如果不存在则创建""" + def get_instance(cls, user, gmail_credential_id=None): + """ + 获取用户的Gmail服务实例,如果不存在则创建 + + 参数: + user: 用户对象 + gmail_credential_id: 指定Gmail凭证ID,如果为None,则使用默认凭证 + """ user_id = str(user.id) - if user_id not in cls._instances: + # 生成实例键,组合用户ID和Gmail凭证ID + if gmail_credential_id: + instance_key = f"{user_id}:{gmail_credential_id}" + else: + instance_key = user_id + + if instance_key not in cls._instances: try: # 从数据库获取认证信息 - credential = GmailCredential.objects.filter(user=user, is_active=True).first() + if gmail_credential_id: + # 获取指定ID的凭证 + credential = GmailCredential.objects.filter( + id=gmail_credential_id, + user=user, + is_active=True + ).first() + else: + # 获取默认凭证,优先获取is_default=True的凭证 + credential = GmailCredential.objects.filter( + user=user, + is_active=True, + is_default=True + ).first() + + # 如果没有找到默认凭证,则获取最近更新的一个凭证 + if not credential: + credential = GmailCredential.objects.filter( + user=user, + is_active=True + ).order_by('-updated_at').first() if credential and credential.credentials: # 反序列化凭证 @@ -58,50 +96,141 @@ class GmailServiceManager: gmail_service = discovery.build('gmail', 'v1', http=creds.authorize(Http())) # 存储实例 - cls._instances[user_id] = { + cls._instances[instance_key] = { 'service': gmail_service, 'credentials': creds, 'timestamp': timezone.now(), - 'user': user + 'user': user, + 'gmail_credential': credential } - logger.info(f"创建用户 {user.username} 的Gmail服务单例") - return cls._instances[user_id] + logger.info(f"创建用户 {user.username} 的Gmail服务单例,Gmail账号: {credential.gmail_email or '未知'},名称: {credential.name}") + return cls._instances[instance_key] except Exception as e: logger.error(f"创建Gmail服务单例失败: {e}") else: # 检查实例是否过期(超过30分钟) - instance = cls._instances[user_id] + instance = cls._instances[instance_key] time_diff = timezone.now() - instance['timestamp'] if time_diff.total_seconds() > 1800: # 30分钟过期 - del cls._instances[user_id] - return cls.get_instance(user) # 递归调用,重新创建 + del cls._instances[instance_key] + return cls.get_instance(user, gmail_credential_id) # 递归调用,重新创建 # 更新时间戳 - cls._instances[user_id]['timestamp'] = timezone.now() - logger.info(f"复用用户 {user.username} 的Gmail服务单例") - return cls._instances[user_id] + cls._instances[instance_key]['timestamp'] = timezone.now() + credential = instance.get('gmail_credential') + gmail_email = credential.gmail_email if credential else '未知' + credential_name = credential.name if credential else '默认' + logger.info(f"复用用户 {user.username} 的Gmail服务单例,Gmail账号: {gmail_email},名称: {credential_name}") + return cls._instances[instance_key] return None @classmethod - def update_instance(cls, user, credentials, service): + def get_credential_instance(cls, credential): + """通过GmailCredential对象获取服务实例""" + if not credential or not credential.user: + return None + return cls.get_instance(credential.user, str(credential.id)) + + @classmethod + def update_instance(cls, user, credentials, service, gmail_credential=None): """更新用户的Gmail服务实例""" user_id = str(user.id) - cls._instances[user_id] = { + + # 确定实例键 + if gmail_credential: + instance_key = f"{user_id}:{gmail_credential.id}" + else: + instance_key = user_id + + cls._instances[instance_key] = { 'service': service, 'credentials': credentials, 'timestamp': timezone.now(), - 'user': user + 'user': user, + 'gmail_credential': gmail_credential } @classmethod - def clear_instance(cls, user): + def clear_instance(cls, user, gmail_credential_id=None): """清除用户的Gmail服务实例""" user_id = str(user.id) - if user_id in cls._instances: - del cls._instances[user_id] + + # 清除特定凭证的实例 + if gmail_credential_id: + instance_key = f"{user_id}:{gmail_credential_id}" + if instance_key in cls._instances: + del cls._instances[instance_key] + else: + # 清除该用户的所有实例 + keys_to_delete = [] + for key in cls._instances.keys(): + if key == user_id or key.startswith(f"{user_id}:"): + keys_to_delete.append(key) + + for key in keys_to_delete: + del cls._instances[key] + + @classmethod + def get_all_user_instances(cls, user): + """获取用户的所有Gmail服务实例""" + user_id = str(user.id) + user_instances = {} + + # 收集该用户所有的实例 + for key, instance in cls._instances.items(): + if key == user_id or key.startswith(f"{user_id}:"): + credential = instance.get('gmail_credential') + if credential: + user_instances[str(credential.id)] = instance + + return user_instances + + @classmethod + def clear_all_instances_by_email(cls, gmail_email): + """ + 清除与特定Gmail邮箱关联的所有服务实例 + + 参数: + gmail_email: Gmail邮箱地址 + + 返回: + int: 清除的实例数量 + """ + try: + # 导入GmailCredential模型 + from .models import GmailCredential + + # 查找与该邮箱关联的所有凭证 + credentials = GmailCredential.objects.filter( + gmail_email=gmail_email, + is_active=True + ).select_related('user') + + # 记录清除的实例数量 + cleared_count = 0 + + # 清除每个凭证关联的实例 + for credential in credentials: + user = credential.user + # 清除特定用户和凭证的实例 + if cls.clear_instance(user, str(credential.id)): + cleared_count += 1 + + # 同时清除用户的默认实例 + user_id = str(user.id) + if user_id in cls._instances: + del cls._instances[user_id] + cleared_count += 1 + + return cleared_count + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.error(f"清除Gmail邮箱 {gmail_email} 的服务实例时出错: {str(e)}") + return 0 class GmailIntegration: """Gmail集成类""" @@ -109,7 +238,7 @@ class GmailIntegration: # Gmail API 权限范围 SCOPES = ['https://mail.google.com/'] - def __init__(self, user, email=None, client_secret_json=None, use_proxy=True, proxy_url='http://127.0.0.1:7890'): + def __init__(self, user, email=None, client_secret_json=None, use_proxy=True, proxy_url='http://127.0.0.1:7890', gmail_credential_id=None): self.user = user self.user_email = user.email if user else None self.email = email # 目标邮箱 @@ -118,6 +247,8 @@ class GmailIntegration: self.gmail_service = None self.use_proxy = use_proxy self.proxy_url = proxy_url + self.gmail_credential_id = gmail_credential_id + self.gmail_credential = None # 设置代理 if self.use_proxy: @@ -129,6 +260,8 @@ class GmailIntegration: if user: # 使用用户ID创建唯一的token存储路径 token_file = f"gmail_token_{user.id}.json" + if gmail_credential_id: + token_file = f"gmail_token_{user.id}_{gmail_credential_id}.json" self.token_storage_path = os.path.join("gmail_tokens", token_file) # 确保目录存在 @@ -137,14 +270,47 @@ class GmailIntegration: # 尝试从数据库加载凭证 try: - gmail_cred = GmailCredential.objects.filter(user=user, is_active=True).first() - if gmail_cred and gmail_cred.credentials: - logger.info(f"从数据库加载用户 {user.username} 的Gmail凭证") - self.credentials = pickle.loads(gmail_cred.credentials) + if gmail_credential_id: + # 加载指定ID的凭证 + gmail_cred = GmailCredential.objects.filter( + id=gmail_credential_id, + user=user, + is_active=True + ).first() + else: + # 加载默认凭证 + gmail_cred = GmailCredential.objects.filter( + user=user, + is_active=True, + is_default=True + ).first() - # 初始化Gmail服务 - self.gmail_service = discovery.build('gmail', 'v1', http=self.credentials.authorize(Http())) - logger.info("从数据库凭证初始化Gmail服务成功") + # 如果没有默认凭证,加载最新的一个 + if not gmail_cred: + gmail_cred = GmailCredential.objects.filter( + user=user, + is_active=True + ).order_by('-updated_at').first() + + if gmail_cred and gmail_cred.credentials: + self.gmail_credential = gmail_cred + logger.info(f"从数据库加载用户 {user.username} 的Gmail凭证 (ID: {gmail_cred.id}, Email: {gmail_cred.gmail_email or '未知'}, 名称: {gmail_cred.name})") + + # 使用新方法加载凭证 + creds = self._load_credentials_from_storage(gmail_cred.credentials) + + if creds: + self.credentials = creds + # 初始化Gmail服务 + self.gmail_service = discovery.build('gmail', 'v1', http=creds.authorize(Http())) + logger.info("从数据库凭证初始化Gmail服务成功") + else: + logger.error("无法从数据库中加载有效凭证") + # 标记需要重新认证 + if hasattr(gmail_cred, 'needs_reauth'): + gmail_cred.needs_reauth = True + gmail_cred.save() + logger.info(f"已将Gmail账号 {gmail_cred.gmail_email} 标记为需要重新认证") except Exception as e: logger.error(f"从数据库加载凭证失败: {str(e)}") # 继续使用文件方式 @@ -160,11 +326,15 @@ class GmailIntegration: try: # 优先尝试使用单例服务 if self.user: - instance = GmailServiceManager.get_instance(self.user) + instance = GmailServiceManager.get_instance(self.user, self.gmail_credential_id) if instance: self.gmail_service = instance['service'] self.credentials = instance['credentials'] - logger.info("使用现有的Gmail服务单例") + self.gmail_credential = instance.get('gmail_credential') + credential_id = str(self.gmail_credential.id) if self.gmail_credential else "未知" + gmail_email = self.gmail_credential.gmail_email if self.gmail_credential else "未知" + credential_name = self.gmail_credential.name if self.gmail_credential else "默认" + logger.info(f"使用现有的Gmail服务单例 (ID: {credential_id}, Email: {gmail_email}, 名称: {credential_name})") return True # 以下是原有的认证逻辑... @@ -175,6 +345,7 @@ class GmailIntegration: if isinstance(self.client_secret, str): try: # 确保是有效的JSON + import json # 在本地作用域引入 json_data = json.loads(self.client_secret) # 强制设置redirect_uris为非浏览器模式,避免localhost连接拒绝问题 for key in ['web', 'installed']: @@ -187,6 +358,7 @@ class GmailIntegration: return False else: # 如果是字典,也进行相同处理 + import json # 在本地作用域引入 client_secret_dict = dict(self.client_secret) for key in ['web', 'installed']: if key in client_secret_dict and 'redirect_uris' in client_secret_dict[key]: @@ -267,26 +439,99 @@ class GmailIntegration: from django.utils import timezone logger.info("保存凭证到数据库") - # 将凭证对象序列化 - credentials_data = pickle.dumps(creds) + # 将凭证对象序列化 - 改为JSON序列化 + try: + # 首先尝试使用JSON序列化 + credentials_data = creds.to_json() + except Exception as json_error: + logger.error(f"JSON序列化凭证失败: {str(json_error)}") + # 备选使用pickle序列化 + credentials_data = pickle.dumps(creds) + logger.info("使用pickle序列化凭证") - # 更新或创建凭证记录,添加gmail_email字段 - gmail_credential, created = GmailCredential.objects.update_or_create( - user=self.user, - defaults={ - 'credentials': credentials_data, - 'token_path': self.token_storage_path, - 'gmail_email': gmail_email, # 添加实际Gmail账号 - 'updated_at': timezone.now(), - 'is_active': True - } - ) - action = "创建" if created else "更新" - logger.info(f"已{action}用户 {self.user.username} 的Gmail凭证记录,实际Gmail账号: {gmail_email}") + # 如果提供了具体的gmail_credential_id,更新对应的记录 + if self.gmail_credential_id: + # 更新指定ID的凭证 + try: + gmail_credential = GmailCredential.objects.get( + id=self.gmail_credential_id, + user=self.user + ) + + # 更新凭证信息 + gmail_credential.credentials = credentials_data + gmail_credential.gmail_email = gmail_email + gmail_credential.updated_at = timezone.now() + gmail_credential.is_active = True + gmail_credential.save() + + self.gmail_credential = gmail_credential + logger.info(f"已更新ID为 {self.gmail_credential_id} 的Gmail凭证,Gmail账号: {gmail_email}") + except GmailCredential.DoesNotExist: + logger.error(f"未找到ID为 {self.gmail_credential_id} 的Gmail凭证,将创建新凭证") + # 如果指定的凭证不存在,创建新凭证 + self.gmail_credential_id = None + + # 如果没有具体的gmail_credential_id,或者指定的不存在,创建或更新默认凭证 + if not self.gmail_credential_id: + # 检查是否已存在相同gmail_email的凭证 + existing_credential = None + if gmail_email: + existing_credential = GmailCredential.objects.filter( + user=self.user, + gmail_email=gmail_email, + is_active=True + ).first() + + if existing_credential: + # 更新现有凭证 + existing_credential.credentials = credentials_data + existing_credential.token_path = self.token_storage_path + existing_credential.updated_at = timezone.now() + existing_credential.save() + + self.gmail_credential = existing_credential + logger.info(f"已更新Gmail账号 {gmail_email} 的现有凭证") + else: + # 获取Gmail账户数量 + gmail_count = GmailCredential.objects.filter(user=self.user).count() + + # 创建新凭证 + name = f"Gmail账号 {gmail_count + 1}" + if gmail_email: + name = gmail_email + else: + # 确保gmail_email有默认值,避免null错误 + gmail_email = "未知邮箱" + + # 将凭证转换为JSON字符串 + if isinstance(credentials_data, dict): + # 确保json模块在本地作用域可访问 + import json + credentials_data = json.dumps(credentials_data) + + gmail_credential = GmailCredential.objects.create( + user=self.user, + credentials=credentials_data, + token_path=self.token_storage_path, + gmail_email=gmail_email, + name=name, + is_default=(gmail_count == 0), # 第一个账号设为默认 + updated_at=timezone.now(), + is_active=True + ) + + self.gmail_credential = gmail_credential + logger.info(f"已创建新的Gmail凭证,Gmail账号: {gmail_email}, 名称: {name}") # 认证成功后更新单例 if self.user and self.gmail_service and self.credentials: - GmailServiceManager.update_instance(self.user, self.credentials, self.gmail_service) + GmailServiceManager.update_instance( + self.user, + self.credentials, + self.gmail_service, + self.gmail_credential + ) return True @@ -379,6 +624,24 @@ class GmailIntegration: if existing_kb: logger.info(f"找到现有知识库: {kb_name}") + # 检查external_id是否存在,如果不存在则创建 + if not existing_kb.external_id: + logger.info(f"知识库 {kb_name} 缺少external_id,尝试创建外部知识库") + try: + from .views import KnowledgeBaseViewSet + kb_viewset = KnowledgeBaseViewSet() + external_id = kb_viewset._create_external_dataset(existing_kb) + if external_id: + existing_kb.external_id = external_id + existing_kb.save() + logger.info(f"成功为现有知识库创建外部知识库: {external_id}") + else: + logger.error("创建外部知识库失败:未返回external_id") + return {"conversation_id": None, "success": False, "error": "创建外部知识库失败"} + except Exception as e: + logger.error(f"为现有知识库创建外部知识库失败: {str(e)}") + return {"conversation_id": None, "success": False, "error": f"创建外部知识库失败: {str(e)}"} + # 创建映射关系 GmailTalentMapping.objects.update_or_create( user=self.user, @@ -720,16 +983,34 @@ class GmailIntegration: """ try: # 导入所需模型 - from .models import GmailAttachment, ChatHistory + from .models import GmailAttachment, ChatHistory, KnowledgeBaseDocument, GmailTalentMapping # 检查入参 if not conversations or not knowledge_base: logger.error("参数不完整: conversations或knowledge_base为空") - return False + return {"conversation_id": None, "success": False, "error": "参数不完整"} if not conversations: logger.warning("没有邮件对话可保存") return {"conversation_id": None, "success": False, "error": "没有邮件对话可保存"} + + # 确保knowledge_base有external_id + if not knowledge_base.external_id: + logger.warning(f"知识库 {knowledge_base.name} 缺少external_id,尝试创建外部知识库") + try: + from .views import KnowledgeBaseViewSet + kb_viewset = KnowledgeBaseViewSet() + external_id = kb_viewset._create_external_dataset(knowledge_base) + if external_id: + knowledge_base.external_id = external_id + knowledge_base.save() + logger.info(f"成功为知识库创建外部知识库: {external_id}") + else: + logger.error("创建外部知识库失败:未返回external_id") + return {"conversation_id": None, "success": False, "error": "创建外部知识库失败"} + except Exception as e: + logger.error(f"为知识库创建外部知识库失败: {str(e)}") + return {"conversation_id": None, "success": False, "error": f"创建外部知识库失败: {str(e)}"} # 查找现有的对话ID - 优先使用talent_email查找 conversation_id = None @@ -846,18 +1127,41 @@ class GmailIntegration: # 格式可能直接是邮箱 sender_email = sender - # 根据邮箱判断角色:检查发件人与用户邮箱或者目标邮箱是否匹配 + # 修改角色判断逻辑:根据Gmail凭证邮箱和达人邮箱判断 + # 获取当前Gmail凭证邮箱 + gmail_credential_email = None + if self.gmail_credential and self.gmail_credential.gmail_email: + gmail_credential_email = self.gmail_credential.gmail_email.lower() + + # 判断是否是凭证邮箱发出的邮件 + is_credential_email = gmail_credential_email and gmail_credential_email == sender_email.lower() + + # 判断是否是系统用户邮箱 is_user_email = self.user.email.lower() == sender_email.lower() - # 检查是否是目标邮箱(talent_email) + # 判断是否是目标达人邮箱 is_talent_email = False - if self.email and sender_email.lower() == self.email.lower(): - is_talent_email = True + if self.email: + is_talent_email = self.email.lower() == sender_email.lower() - # 如果是用户邮箱或目标邮箱,则为user角色,否则为assistant - role = 'user' if is_user_email or is_talent_email else 'assistant' + # 如果是Gmail凭证邮箱或用户邮箱,设为user角色;如果是达人邮箱,设为assistant角色 + if is_credential_email or is_user_email: + role = 'user' + elif is_talent_email: + role = 'assistant' + else: + # 尝试检查是否有与发件人邮箱相匹配的人才映射 + from .models import GmailTalentMapping + talent_mapping = GmailTalentMapping.objects.filter( + user=self.user, + talent_email=sender_email, + is_active=True + ).first() + + # 如果有映射关系则视为达人邮箱,设为assistant角色 + role = 'assistant' if talent_mapping else 'user' - logger.info(f"设置消息角色: {role},发件人: {sender_email},用户邮箱: {self.user.email},目标邮箱: {self.email}") + logger.info(f"设置消息角色: {role},发件人: {sender_email},用户Gmail: {gmail_credential_email},用户邮箱: {self.user.email},目标达人: {self.email},是否有达人映射: {bool(talent_mapping) if 'talent_mapping' in locals() else False}") # 将邮件添加到文档 paragraph = { @@ -996,10 +1300,24 @@ class GmailIntegration: "problem_list": [] }) + # 检查paragraphs是否为空 + if not doc_data["paragraphs"]: + logger.warning("没有段落内容可上传,添加默认段落") + doc_data["paragraphs"].append({ + "title": "初始化邮件对话", + "content": f"与{self.email or '联系人'}的邮件对话准备就绪,等待新的邮件内容。", + "is_active": True, + "problem_list": [] + }) + # 调用知识库的文档上传API from .views import KnowledgeBaseViewSet kb_viewset = KnowledgeBaseViewSet() + # 添加随机文档ID + doc_data["id"] = str(uuid.uuid4()) + logger.info(f"生成随机文档ID: {doc_data['id']}") + # 上传文档 upload_response = kb_viewset._call_upload_api(knowledge_base.external_id, doc_data) @@ -1322,6 +1640,13 @@ class GmailIntegration: project_id = getattr(settings, 'GOOGLE_CLOUD_PROJECT', 'knowledge-454905') logger.info(f"使用settings中配置的项目ID: {project_id}") + # 确保Gmail服务已经初始化 + if not hasattr(self, 'gmail_service') or not self.gmail_service: + logger.warning("Gmail服务未初始化,尝试重新认证") + if not self.authenticate(): + logger.error("Gmail服务初始化失败") + return None + # 注意:不再使用用户邮箱作为判断依据,Gmail API总是使用'me'作为userId # Gmail认证是通过OAuth流程,与系统用户邮箱无关 logger.info(f"系统用户: {self.user.email},Gmail API使用OAuth认证的邮箱 (userId='me')") @@ -1355,8 +1680,19 @@ class GmailIntegration: logger.info(f"设置Gmail监听: {request}") - # 执行watch请求 - response = self.gmail_service.users().watch(userId='me', body=request).execute() + # 设置超时,避免长时间阻塞 + original_timeout = socket.getdefaulttimeout() + socket.setdefaulttimeout(GMAIL_REQUEST_TIMEOUT) + + try: + # 执行watch请求 + response = self.gmail_service.users().watch(userId='me', body=request).execute() + except Exception as e: + logger.error(f"执行watch请求失败: {str(e)}") + return None + finally: + # 恢复原始超时设置 + socket.setdefaulttimeout(original_timeout) # 获取historyId,用于后续同步 history_id = response.get('historyId') @@ -1366,6 +1702,7 @@ class GmailIntegration: # 保存监听信息到数据库 if self.user: + from .models import GmailCredential credential = GmailCredential.objects.filter(user=self.user, is_active=True).first() if credential: # 转换时间戳为datetime @@ -1404,15 +1741,35 @@ class GmailIntegration: except Exception as e: logger.error(f"设置Gmail监听失败: {str(e)}") logger.error(traceback.format_exc()) - raise + return None def get_history(self, start_history_id): """获取历史变更""" try: logger.info(f"获取历史记录,起始ID: {start_history_id}") - response = self.gmail_service.users().history().list( - userId='me', startHistoryId=start_history_id - ).execute() + + # 确保Gmail服务已经初始化 + if not hasattr(self, 'gmail_service') or not self.gmail_service: + logger.warning("Gmail服务未初始化,尝试重新认证") + if not self.authenticate(): + logger.error("Gmail服务初始化失败") + return [] + + # 设置超时,避免长时间阻塞 + original_timeout = socket.getdefaulttimeout() + socket.setdefaulttimeout(GMAIL_REQUEST_TIMEOUT) + + try: + # 执行history请求 + response = self.gmail_service.users().history().list( + userId='me', startHistoryId=start_history_id + ).execute() + except Exception as e: + logger.error(f"执行history请求失败: {str(e)}") + return [] + finally: + # 恢复原始超时设置 + socket.setdefaulttimeout(original_timeout) logger.info(f"历史记录响应: {response}") @@ -1421,15 +1778,34 @@ class GmailIntegration: history_list.extend(response['history']) logger.info(f"找到 {len(response['history'])} 个历史记录") - # 获取所有页 - while 'nextPageToken' in response: + # 获取所有页,但每次请求都设置超时 + page_count = 0 + max_pages = 5 # 限制最大页数,避免无限循环 + + while 'nextPageToken' in response and page_count < max_pages: page_token = response['nextPageToken'] - response = self.gmail_service.users().history().list( - userId='me', startHistoryId=start_history_id, pageToken=page_token - ).execute() + page_count += 1 + + # 设置超时 + socket.setdefaulttimeout(GMAIL_REQUEST_TIMEOUT) + + try: + response = self.gmail_service.users().history().list( + userId='me', startHistoryId=start_history_id, pageToken=page_token + ).execute() + except Exception as e: + logger.error(f"获取历史记录分页失败: {str(e)}") + break + finally: + # 恢复原始超时设置 + socket.setdefaulttimeout(original_timeout) + if 'history' in response: history_list.extend(response['history']) - logger.info(f"加载额外 {len(response['history'])} 个历史记录") + logger.info(f"加载额外 {len(response['history'])} 个历史记录 (页 {page_count})") + + if page_count >= max_pages and 'nextPageToken' in response: + logger.warning(f"达到最大页数限制 ({max_pages}),可能有更多历史记录未获取") else: logger.info(f"没有新的历史记录,最新historyId: {response.get('historyId', 'N/A')}") @@ -1459,6 +1835,7 @@ class GmailIntegration: except Exception as e: logger.error(f"获取Gmail历史记录失败: {str(e)}") + logger.error(traceback.format_exc()) return [] def process_notification(self, notification_data): @@ -1498,13 +1875,13 @@ class GmailIntegration: return False # 查找关联用户 - from .models import User + from .models import User, GmailCredential user = User.objects.filter(email=email).first() + credential = None # 如果找不到用户,尝试使用gmail_email字段查找 if not user: logger.info(f"找不到email={email}的用户,尝试使用gmail_email查找") - from .models import GmailCredential credential = GmailCredential.objects.filter(gmail_email=email, is_active=True).first() if credential: user = credential.user @@ -1514,472 +1891,438 @@ class GmailIntegration: logger.error(f"找不到与 {email} 关联的用户") return False + # 确保有相应的Gmail凭证 + if not credential: + credential = GmailCredential.objects.filter(user=user, is_active=True).first() + if not credential: + logger.error(f"用户 {user.email} 没有活跃的Gmail凭证") + return False + + # 检查凭证是否需要重新授权 + if credential.needs_reauth: + logger.warning(f"Gmail凭证 {credential.id} 需要重新授权,无法处理通知") + + # 记录当前通知到队列或数据库,供用户重新授权后再处理 + try: + from .models import GmailNotificationQueue + + # 存储通知到队列 + GmailNotificationQueue.objects.create( + user=user, + gmail_credential=credential, + email=email, + history_id=history_id, + notification_data=json.dumps(notification_data) + ) + logger.info(f"通知已保存到队列,等待用户重新授权后处理") + except Exception as queue_error: + logger.error(f"保存通知到队列失败: {str(queue_error)}") + + return False + + # 更新历史ID + if credential and history_id: + try: + # 仅当新的历史ID大于当前值时更新 + if not credential.last_history_id or int(history_id) > int(credential.last_history_id): + credential.last_history_id = history_id + credential.save() + logger.info(f"更新历史ID: {history_id}") + else: + logger.info(f"收到的历史ID ({history_id}) 不大于当前值 ({credential.last_history_id}),不更新") + except Exception as update_error: + logger.error(f"更新历史ID失败: {str(update_error)}") + # 初始化Gmail集成 - gmail_integration = GmailIntegration(user) - if not gmail_integration.authenticate(): - logger.error(f"Gmail认证失败: {email}") - return False + gmail_integration = GmailIntegration(user, gmail_credential_id=credential.id if credential else None) + + # 记录详细的处理信息 + logger.info(f"Gmail通知处理: 用户={user.email}, Gmail邮箱={email}, 历史ID={history_id}, 凭证ID={credential.id}") + + # 设置超时 + original_timeout = socket.getdefaulttimeout() + socket.setdefaulttimeout(GMAIL_REQUEST_TIMEOUT) + + try: + # 认证Gmail服务 + if not gmail_integration.authenticate(): + logger.error(f"Gmail认证失败: {email}") + + # 尝试刷新令牌 + refresh_result = gmail_integration.refresh_token() + if not refresh_result: + logger.error("刷新令牌失败,需要用户重新授权") + return False + + logger.info("令牌刷新成功,继续处理通知") + + # 获取历史变更 + message_ids = gmail_integration.get_history(history_id) + + # 如果没有找到新消息,尝试获取最近的消息 + if not message_ids: + logger.info("没有找到历史变更,尝试获取最近的邮件") + + # 查询最近的5封邮件 + try: + recent_messages = gmail_integration.gmail_service.users().messages().list( + userId='me', + maxResults=5 + ).execute() + + if 'messages' in recent_messages: + message_ids = [msg['id'] for msg in recent_messages['messages']] + logger.info(f"获取到 {len(message_ids)} 封最近的邮件: {message_ids}") + else: + logger.info("没有找到最近的邮件") + except Exception as recent_error: + logger.error(f"获取最近邮件失败: {str(recent_error)}") + + except Exception as e: + error_msg = str(e) + logger.error(f"处理Gmail通知时发生错误: {error_msg}") + + # 检查是否是令牌过期 + if "invalid_grant" in error_msg.lower() or "401" in error_msg: + logger.warning("检测到OAuth令牌问题,尝试刷新令牌") + + # 尝试刷新令牌 + refresh_result = gmail_integration.refresh_token() + if not refresh_result: + logger.error("刷新令牌失败,需要用户重新授权") + return False + + # 再次尝试获取历史记录 + try: + message_ids = gmail_integration.get_history(history_id) + + # 如果仍然没有找到新消息,尝试获取最近的消息 + if not message_ids: + logger.info("刷新令牌后仍未找到历史变更,尝试获取最近邮件") + + recent_messages = gmail_integration.gmail_service.users().messages().list( + userId='me', + maxResults=5 + ).execute() + + if 'messages' in recent_messages: + message_ids = [msg['id'] for msg in recent_messages['messages']] + logger.info(f"获取到 {len(message_ids)} 封最近的邮件: {message_ids}") + except Exception as retry_error: + logger.error(f"刷新令牌后再次尝试失败: {str(retry_error)}") + return False + else: + return False + finally: + # 恢复原始超时设置 + socket.setdefaulttimeout(original_timeout) - # 首先尝试使用历史记录API获取新消息 - message_ids = gmail_integration.get_history(history_id) if message_ids: - logger.info(f"从历史记录找到 {len(message_ids)} 个新消息") - # 处理每个新消息 + logger.info(f"找到 {len(message_ids)} 个需要处理的消息") + + # 限制处理的消息数量,防止过多消息导致系统负载过高 + max_messages = 10 + if len(message_ids) > max_messages: + logger.warning(f"消息数量 ({len(message_ids)}) 超过限制 ({max_messages}),将只处理前 {max_messages} 条") + message_ids = message_ids[:max_messages] + + # 处理消息 + success_count = 0 for message_id in message_ids: - self._process_new_message(gmail_integration, message_id) - return True + try: + # 设置超时 + socket.setdefaulttimeout(GMAIL_REQUEST_TIMEOUT) + + if self._process_new_message(gmail_integration, message_id): + success_count += 1 + except Exception as msg_error: + error_msg = str(msg_error) + logger.error(f"处理消息 {message_id} 失败: {error_msg}") + + # 检查是否是令牌过期 + if "invalid_grant" in error_msg.lower() or "401" in error_msg: + logger.warning("处理消息时检测到OAuth令牌问题,标记需要重新授权") + gmail_integration._mark_credential_needs_reauth() + break + + logger.error(traceback.format_exc()) + finally: + # 恢复原始超时设置 + socket.setdefaulttimeout(original_timeout) + + logger.info(f"成功处理 {success_count}/{len(message_ids)} 个消息") + return success_count > 0 - # 如果历史记录API没有返回新消息,尝试获取最近的对话 - logger.info("历史记录API没有返回新消息,尝试获取达人对话") - - # 查找所有与该用户关联的达人映射 - from .models import GmailTalentMapping - mappings = GmailTalentMapping.objects.filter( - user=user, - is_active=True - ) - - if not mappings.exists(): - logger.info(f"用户 {user.email} 没有达人映射记录") + # 如果没有找到新消息但仍需确认处理,验证GooglePubSub连接并保持通知持续 + try: + # 简单验证连接性测试 + logger.info(f"未找到需要处理的消息,执行服务连接测试") + profile = gmail_integration.gmail_service.users().getProfile(userId='me').execute() + if profile and 'emailAddress' in profile: + logger.info(f"Gmail服务连接正常: {profile['emailAddress']}") + + # 检查并更新监听状态 + if credential: + # 检查监听是否过期 + needs_watch_renew = False + if credential.watch_expiration: + from django.utils import timezone + # 如果监听将在3天内过期,提前更新 + three_days_later = timezone.now() + timezone.timedelta(days=3) + if credential.watch_expiration < three_days_later: + needs_watch_renew = True + logger.info(f"监听将在3天内过期,需要更新: {credential.watch_expiration}") + else: + needs_watch_renew = True + logger.info("没有监听过期时间记录,需要更新") + + if needs_watch_renew: + try: + # 更新监听 + watch_result = gmail_integration.setup_watch() + logger.info(f"更新监听成功: {watch_result}") + return True + except Exception as watch_error: + logger.error(f"更新监听失败: {str(watch_error)}") + return False + + return True + except Exception as verify_error: + logger.error(f"Gmail服务连接测试失败: {str(verify_error)}") return False - # 处理每个达人映射 - for mapping in mappings: - talent_email = mapping.talent_email - logger.info(f"处理达人 {talent_email} 的对话") - - # 获取达人最近的邮件 - recent_emails = gmail_integration.get_recent_emails( - from_email=talent_email, - max_results=5 # 限制获取最近5封 - ) - - if not recent_emails: - logger.info(f"没有找到来自 {talent_email} 的最近邮件") - continue - - logger.info(f"找到 {len(recent_emails)} 封来自 {talent_email} 的最近邮件") - - # 创建或获取知识库 - knowledge_base, created = gmail_integration.create_talent_knowledge_base(talent_email) - kb_action = "创建" if created else "获取" - logger.info(f"知识库{kb_action}成功: {knowledge_base.name}") - - # 保存对话 - result = gmail_integration.save_conversations_to_knowledge_base(recent_emails, knowledge_base) - logger.info(f"保存达人对话结果: {result}") - + # 如果没有找到新消息,记录日志并返回成功 + logger.info("没有找到新消息,处理完成") return True except Exception as e: logger.error(f"处理Gmail通知失败: {str(e)}") logger.error(traceback.format_exc()) return False - + def _process_new_message(self, gmail_integration, message_id): """处理新收到的邮件""" try: # 导入所需模型 from .models import GmailTalentMapping, GmailAttachment, KnowledgeBase, ChatHistory, UserProfile - # 获取邮件详情 - message = gmail_integration.gmail_service.users().messages().get( - userId='me', id=message_id - ).execute() + # 添加详细日志 + logger.info(f"处理新消息ID: {message_id}") - # 提取邮件内容 - email_data = gmail_integration._extract_email_content(message) - if not email_data: - logger.error(f"提取邮件内容失败: {message_id}") + # 获取消息内容 + message = gmail_integration.gmail_service.users().messages().get(userId='me', id=message_id).execute() + + # 从邮件中提取相关信息 + email_content = gmail_integration._extract_email_content(message) + if not email_content: + logger.error(f"无法提取消息 {message_id} 的内容") return False - - # 获取发件人邮箱 - from_email = email_data.get('from', '') - sender_email = '' - if '<' in from_email and '>' in from_email: - # 格式如 "姓名 " - sender_email = from_email.split('<')[1].split('>')[0] - else: - # 格式可能直接是邮箱 - sender_email = from_email - - # 根据邮箱判断角色:检查发件人与用户邮箱或者映射的talent邮箱是否匹配 - is_user_email = gmail_integration.user.email.lower() == sender_email.lower() - - # 检查是否有与当前用户关联的talent邮箱映射 - is_mapped_talent = False - talent_mapping = GmailTalentMapping.objects.filter( - user=gmail_integration.user, - talent_email=sender_email, - is_active=True - ).first() # 修改为first()以获取实际的对象而不是布尔值 - - # 如果是用户邮箱或映射的talent邮箱,则为user角色,否则为assistant - role = 'user' if is_user_email or talent_mapping else 'assistant' - - logger.info(f"设置消息角色: {role}, 发件人: {sender_email}, 用户邮箱: {gmail_integration.user.email}, 是否映射达人: {talent_mapping}") - - # 查找是否有关联的达人知识库 - kb_name = f"Gmail-{sender_email.split('@')[0]}" - knowledge_base = KnowledgeBase.objects.filter(name=kb_name).first() - - # 如果没有以发件人邮箱命名的知识库,尝试查找自定义知识库 - if not knowledge_base: - # 查找映射关系 - mapping = GmailTalentMapping.objects.filter( - talent_email=sender_email, - user=gmail_integration.user, - is_active=True - ).first() - if mapping and mapping.knowledge_base: - knowledge_base = mapping.knowledge_base - logger.info(f"使用映射的知识库: {knowledge_base.name}") - else: - logger.info(f"收到新邮件,但没有找到关联的达人知识库: {sender_email}") + # 记录邮件详情 + sender = email_content.get('from', '') + recipient = email_content.get('to', '') + subject = email_content.get('subject', '') + body = email_content.get('body', '') + + logger.info(f"提取的邮件信息: 发件人={sender}, 收件人={recipient}, 主题={subject}") + + # 提取达人邮箱 - 可能是发件人或收件人 + talent_email = None + is_talent_sending = False # 标记是否是达人发送的邮件 + + # 检查收件人是否是当前用户 + user_email = None + if self.user and hasattr(self.user, 'email'): + user_email = self.user.email + + # 如果发件人不是用户,则认为发件人是达人 + if user_email and sender and user_email.lower() not in sender.lower(): + talent_email = sender.lower() + is_talent_sending = True + logger.info(f"检测到达人(发件人)邮箱: {talent_email}") + # 如果收件人不是用户,则认为收件人是达人 + elif user_email and recipient and user_email.lower() not in recipient.lower(): + talent_email = recipient.lower() + is_talent_sending = False + logger.info(f"检测到达人(收件人)邮箱: {talent_email}") + + # 从邮箱中提取纯地址(去除名称部分) + if talent_email: + if '<' in talent_email and '>' in talent_email: + talent_email = talent_email.split('<')[1].split('>')[0] + + # 转换为小写,提高匹配准确性 + talent_email = talent_email.lower() + + # 如果找不到明确的达人邮箱,尝试从映射中查找 + if not talent_email: + # 尝试从主题或正文中找线索 + # 实现取决于具体需求 + pass + + logger.info(f"最终确定的达人邮箱: {talent_email}") + + # 如果找到了达人邮箱,进行知识库处理 + if talent_email: + # 创建或获取知识库 + knowledge_base, created = self.create_talent_knowledge_base(talent_email) + + if not knowledge_base: + logger.error(f"无法为达人 {talent_email} 创建知识库") return False - - # 查找关联的对话ID - conversation_id = None - - # 1. 首先通过talent_mapping查找 - if talent_mapping and talent_mapping.conversation_id: - conversation_id = talent_mapping.conversation_id - logger.info(f"通过达人映射找到对话ID: {conversation_id}") - - # 2. 如果没有找到,尝试通过知识库查找任何相关对话 - if not conversation_id: - existing_conversation = ChatHistory.objects.filter( - knowledge_base=knowledge_base, - user=gmail_integration.user, - is_deleted=False - ).values('conversation_id').distinct().first() - - if existing_conversation: - conversation_id = existing_conversation['conversation_id'] - logger.info(f"通过知识库 {knowledge_base.name} 找到现有对话ID: {conversation_id}") - # 更新或创建映射关系 - if not talent_mapping: - GmailTalentMapping.objects.update_or_create( - user=gmail_integration.user, - talent_email=sender_email, - defaults={ - 'knowledge_base': knowledge_base, - 'conversation_id': conversation_id, - 'is_active': True - } - ) - logger.info(f"更新Gmail达人映射: {sender_email} -> 对话ID {conversation_id}") - - # 3. 如果仍没找到,创建新的对话ID - if not conversation_id: - conversation_id = str(uuid.uuid4()) - logger.info(f"创建新的对话ID: {conversation_id}") + logger.info(f"使用知识库: {knowledge_base.name} (ID: {knowledge_base.id}), 新创建: {created}") - # 保存映射关系 - if not talent_mapping: - GmailTalentMapping.objects.create( - user=gmail_integration.user, - talent_email=sender_email, - knowledge_base=knowledge_base, - conversation_id=conversation_id, - is_active=True - ) - logger.info(f"已创建新的Gmail达人映射: {sender_email} -> {knowledge_base.name}") - - # 检查消息是否已经处理过 - if ChatHistory.objects.filter( - conversation_id=conversation_id, - metadata__gmail_message_id=message_id, - is_deleted=False - ).exists(): - logger.info(f"邮件已处理过,跳过: {message_id}") - return True - - # 解析邮件日期 - date_str = email_data.get('date', '') - logger.info(f"开始解析邮件日期: '{date_str}'") - - try: - # 先检查是否是时间戳格式 - if isinstance(date_str, str) and date_str.isdigit(): - # 如果是时间戳,直接转换 - date_obj = datetime.fromtimestamp(int(date_str)) - logger.info(f"从时间戳解析的日期: {date_obj}") - else: - # 尝试标准格式解析 - try: - date_obj = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S') - logger.info(f"从标准格式解析的日期: {date_obj}") - except ValueError: - # 如果标准格式解析失败,使用dateutil更灵活的解析 - import dateutil.parser as date_parser - date_obj = date_parser.parse(date_str) - logger.info(f"从灵活格式解析的日期: {date_obj}, 是否有时区: {date_obj.tzinfo is not None}") - - # 如果解析出的日期有时区信息且系统不使用时区,转换为不带时区的日期 - if date_obj.tzinfo is not None and hasattr(settings, 'USE_TZ') and not settings.USE_TZ: - date_obj = date_obj.replace(tzinfo=None) - logger.info(f"移除时区信息后: {date_obj}") - - # 确保时区处理正确 - from django.utils import timezone - try: - # 检查date_obj是否已经是aware - if timezone.is_aware(date_obj): - aware_date = date_obj - logger.info(f"日期已经包含时区信息: {aware_date}") - else: - # 如果系统使用时区且日期没有时区信息,添加时区 - if hasattr(settings, 'USE_TZ') and settings.USE_TZ: - aware_date = timezone.make_aware(date_obj) - logger.info(f"日期添加时区信息后: {aware_date}") - else: - # 如果系统不使用时区,保持naive状态 - aware_date = date_obj - logger.info(f"保持日期不带时区: {aware_date}") - except Exception as tz_error: - logger.warning(f"时区转换失败: {str(tz_error)},使用当前时间") - aware_date = timezone.now() - - except (ValueError, TypeError) as e: - logger.warning(f"无法解析邮件日期: '{date_str}',错误: {str(e)},使用当前时间") - from django.utils import timezone - aware_date = timezone.now() - - # 查找适合的parent_id: 使用创建时间排序 - try: - # 查找该对话中最新的消息 - latest_message = ChatHistory.objects.filter( - conversation_id=conversation_id, - is_deleted=False - ).order_by('-created_at').first() - - parent_id = None - if latest_message: - parent_id = str(latest_message.id) - logger.info(f"找到最新消息ID作为parent_id: {parent_id}, 创建时间: {latest_message.created_at}") - else: - logger.info(f"对话 {conversation_id} 没有现有消息,不设置parent_id") - except Exception as e: - logger.error(f"查找父消息失败: {str(e)}") - logger.error(traceback.format_exc()) - parent_id = None - - # 下载附件 - attachment_records = [] - for attachment in email_data.get('attachments', []): - if 'attachmentId' in attachment: - filepath = gmail_integration.download_attachment( - message_id, - attachment['attachmentId'], - attachment['filename'] - ) - - if filepath: - attachment_records.append({ - 'filepath': filepath, - 'filename': attachment['filename'], - 'message_id': message_id, - 'date': date_str - }) - - # 构建metadata - metadata = { - 'gmail_message_id': message_id, - 'from': email_data.get('from', ''), - 'date': date_str, - 'subject': email_data.get('subject', ''), - 'dataset_id_list': [str(knowledge_base.id)], - 'dataset_names': [knowledge_base.name] - } - - if attachment_records: - metadata['message_attachments'] = attachment_records - - # 使用之前查找到的parent_id和aware_date创建聊天记录 - chat_message = ChatHistory.objects.create( - user=gmail_integration.user, - knowledge_base=knowledge_base, - conversation_id=conversation_id, - parent_id=parent_id, # 使用之前查找到的parent_id - role=role, # 使用上面确定的role变量 - content=f"[{email_data['subject']}] {email_data['body']}", - metadata=metadata, - created_at=aware_date # 设置正确的创建时间 - ) - - # 更新知识库文档 - gmail_integration._append_to_knowledge_base_document( - knowledge_base, - email_data['subject'], - email_data['body'], - gmail_integration.user.email - ) - - # 如果有附件,上传到知识库 - if attachment_records: - gmail_integration._upload_message_attachments_to_knowledge_base( - knowledge_base, - attachment_records - ) - - # 添加WebSocket通知功能 - try: - # 导入必要的模块 - from channels.layers import get_channel_layer - from asgiref.sync import async_to_sync - from django.conf import settings - - # 检查是否有WebSocket通道层配置 - channel_layer = get_channel_layer() - if channel_layer: - # 创建通知数据 - notification_data = { - "type": "notification", - "data": { - "message_type": "new_gmail", - "conversation_id": conversation_id, - "message": { - "id": str(chat_message.id), - "role": role, - "content": f"[{email_data['subject']}] {email_data['body'][:100]}{'...' if len(email_data['body']) > 100 else ''}", - "sender": sender_email, - "subject": email_data['subject'], - "has_attachments": len(attachment_records) > 0 - } - } + # 获取映射关系 + talent_mapping, mapping_created = GmailTalentMapping.objects.get_or_create( + user=self.user, + talent_email=talent_email, + defaults={ + 'knowledge_base': knowledge_base, + 'conversation_id': f"gmail_{talent_email.replace('@', '_').replace('.', '_')}", + 'is_active': True } + ) + + # 获取对话ID + conversation_id = talent_mapping.conversation_id + + # 创建聊天记录 - 确定正确的角色 + # 修正角色判断逻辑:达人始终是assistant,用户始终是user + # 如果是达人发送的邮件,角色应该是assistant;如果是用户发送的邮件,角色应该是user + chat_role = 'assistant' if is_talent_sending else 'user' + + # 添加更详细的角色日志 + logger.info(f"设置聊天角色: 角色={chat_role}, 是否达人发送={is_talent_sending}, 发件人={sender}, 达人邮箱={talent_email}") + + # 创建或更新聊天记录 + chat_entry = ChatHistory.objects.create( + user=self.user, + knowledge_base=knowledge_base, + conversation_id=conversation_id, + role=chat_role, + content=f"{subject}\n\n{body}", + parent_id=message_id + ) + + logger.info(f"已创建聊天记录: ID={chat_entry.id}, 角色={chat_role}, 对话ID={conversation_id}") + + # 查找适合的parent_id:获取对话中最后一条消息 + try: + # 获取该对话中非自己的最新消息作为正确的父消息 + last_message = ChatHistory.objects.filter( + conversation_id=conversation_id, + is_deleted=False + ).exclude( + id=chat_entry.id # 排除刚刚创建的消息 + ).order_by('-created_at').first() - # 发送WebSocket消息 - async_to_sync(channel_layer.group_send)( - f"notification_user_{gmail_integration.user.id}", - notification_data - ) - logger.info(f"已发送WebSocket通知: 用户 {gmail_integration.user.id} 收到新Gmail消息") - - # 创建系统通知记录 - try: - from .models import Notification - Notification.objects.create( - sender=gmail_integration.user, - receiver=gmail_integration.user, - title="新Gmail消息", - content=f"您收到了来自 {sender_email} 的新邮件: {email_data['subject']}", - type="system_notice", - related_resource=conversation_id - ) - logger.info(f"已创建系统通知记录: 用户 {gmail_integration.user.id} 的新Gmail消息") - except Exception as notification_error: - logger.error(f"创建系统通知记录失败: {str(notification_error)}") - - # 如果消息是达人发送的,并且用户启用了自动推荐回复功能,则生成推荐回复 - if role == 'user' and talent_mapping: + if last_message: + # 更新parent_id为对话中的上一条消息ID + chat_entry.parent_id = str(last_message.id) + chat_entry.save(update_fields=['parent_id']) + logger.info(f"更新消息 {chat_entry.id} 的parent_id为 {last_message.id}") + except Exception as parent_error: + logger.error(f"更新父消息ID失败: {str(parent_error)}") + + # 处理附件 + if 'attachments' in email_content and email_content['attachments']: + # 下载并处理附件 + for attachment in email_content['attachments']: try: - # 检查用户是否启用了自动推荐回复功能 - user_profile, created = UserProfile.objects.get_or_create(user=gmail_integration.user) + logger.info(f"处理附件: {attachment.get('filename')}") + # 下载附件 + filepath = self.download_attachment( + message_id=message_id, + attachment_id=attachment.get('attachmentId'), + filename=attachment.get('filename') + ) - if user_profile.auto_recommend_reply: - logger.info(f"用户 {gmail_integration.user.id} 已启用自动推荐回复功能,生成推荐回复") + if filepath: + # 创建附件记录 + GmailAttachment.objects.create( + chat_message=chat_entry, + gmail_message_id=message_id, + filename=attachment.get('filename'), + filepath=filepath, + mimetype=attachment.get('mimeType', ''), + filesize=attachment.get('size', 0) + ) + logger.info(f"已保存附件: {filepath}") + except Exception as attachment_error: + logger.error(f"处理附件 {attachment.get('filename')} 失败: {str(attachment_error)}") + + # 检查是否需要自动回复 + try: + # 只有达人发送的邮件才考虑自动回复 + if is_talent_sending and chat_role == 'user': + # 检查用户是否启用了自动回复 + profile = UserProfile.objects.filter(user=self.user).first() + + if profile and profile.auto_recommend_reply: + logger.info(f"用户 {self.user.email} 已启用自动回复,生成回复建议") + + # 获取该对话的历史记录 + conversation_history = ChatHistory.objects.filter( + conversation_id=conversation_id + ).order_by('created_at') + + # 生成回复 + recommended_reply = self._get_recommended_reply_from_deepseek(conversation_history) + + if recommended_reply: + logger.info(f"生成了推荐回复,长度: {len(recommended_reply)}") - # 获取对话历史以传递给DeepSeek API - conversation_messages = ChatHistory.objects.filter( + # 保存推荐回复 + recommended_reply_entry = ChatHistory.objects.create( + user=self.user, + knowledge_base=knowledge_base, conversation_id=conversation_id, - is_deleted=False - ).order_by('created_at') - - # 构建对话历史 - conversation_history = [] - for message in conversation_messages: - conversation_history.append({ - 'role': 'user' if message.role == 'user' else 'assistant', - 'content': message.content - }) - - # 限制对话历史长度,只保留最近的5条消息,避免超出token限制 - recent_messages = conversation_history[-5:] if len(conversation_history) > 5 else conversation_history - messages.extend(recent_messages) - - # 确保最后一条消息是用户消息,如果不是,添加一个提示 - if not recent_messages or recent_messages[-1]['role'] != 'user': - # 添加一个系统消息作为用户的最后一条消息 - messages.append({ - "role": "user", - "content": "请针对我之前的消息提供详细的回复建议。" - }) - - # 调用DeepSeek API生成推荐回复 - recommended_reply = self._get_recommended_reply_from_deepseek(conversation_history) - - if recommended_reply: - # 创建推荐回复通知 - recommend_notification_data = { - "type": "notification", - "data": { - "message_type": "recommended_reply", - "conversation_id": conversation_id, - "message": { - "id": str(chat_message.id), - "role": role, - "content": f"[{email_data['subject']}] {email_data['body'][:100]}{'...' if len(email_data['body']) > 100 else ''}", - "sender": sender_email, - "subject": email_data['subject'], - "recommended_reply": recommended_reply - } - } - } - - # 发送推荐回复WebSocket通知 - async_to_sync(channel_layer.group_send)( - f"notification_user_{gmail_integration.user.id}", - recommend_notification_data - ) - logger.info(f"已发送推荐回复通知: 用户 {gmail_integration.user.id}") - - # 创建推荐回复系统通知 - Notification.objects.create( - sender=gmail_integration.user, - receiver=gmail_integration.user, - title="新推荐回复", - content=f"系统为来自 {sender_email} 的邮件生成了推荐回复", - type="system_notice", - related_resource=conversation_id - ) - logger.info(f"已创建推荐回复系统通知: 用户 {gmail_integration.user.id}") - else: - logger.warning(f"生成推荐回复失败: 用户 {gmail_integration.user.id}, 对话 {conversation_id}") - except Exception as recommend_error: - logger.error(f"处理推荐回复失败: {str(recommend_error)}") - logger.error(traceback.format_exc()) - except Exception as ws_error: - logger.error(f"发送WebSocket通知失败: {str(ws_error)}") - logger.error(traceback.format_exc()) - # 通知失败不影响消息处理流程,继续执行 - - logger.info(f"成功处理新邮件: {message_id} 从 {sender_email}") - return True - + role='user', + content=recommended_reply, + parent_id=chat_entry.id + ) + logger.info(f"已保存推荐回复到历史记录, 角色=user, ID={recommended_reply_entry.id}, 父消息ID={chat_entry.id}") + except Exception as auto_reply_error: + logger.error(f"生成自动回复失败: {str(auto_reply_error)}") + + # 记录详细的处理结果日志 + logger.info(f"成功处理达人邮件: ID={message_id}, 发件人={sender}, 收件人={recipient}, 主题={subject}") + logger.info(f"保存消息信息: 角色={chat_role}, 对话ID={conversation_id}, 是否达人发送={is_talent_sending}") + + return True + else: + logger.warning(f"无法确定达人邮箱,跳过知识库处理") + return False + except Exception as e: - logger.error(f"处理新邮件失败: {str(e)}") + logger.error(f"处理新消息 {message_id} 失败: {str(e)}") logger.error(traceback.format_exc()) return False - + def _get_recommended_reply_from_deepseek(self, conversation_history): - """ - 调用DeepSeek V3 API生成推荐回复 - - 现在结合用户总目标和对话总结生成更有针对性的回复 - """ + """调用DeepSeek API生成回复建议""" try: # 使用有效的API密钥 - api_key = "sk-xqbujijjqqmlmlvkhvxeogqjtzslnhdtqxqgiyuhwpoqcjvf" - # 如果上面的密钥不正确,可以尝试从环境变量或数据库中获取 + api_key = "" + # 尝试从环境变量获取 + import os + from dotenv import load_dotenv + load_dotenv() + env_api_key = os.environ.get('DEEPSEEK_API_KEY') + if env_api_key: + api_key = env_api_key + # 从Django设置中获取密钥 from django.conf import settings if hasattr(settings, 'DEEPSEEK_API_KEY') and settings.DEEPSEEK_API_KEY: api_key = settings.DEEPSEEK_API_KEY + # 如果仍然没有有效的API密钥,使用默认值 + if not api_key: + api_key = "sk-xqbujijjqqmlmlvkhvxeogqjtzslnhdtqxqgiyuhwpoqcjvf" + logger.warning("使用默认API密钥,请在环境变量或settings.py中设置DEEPSEEK_API_KEY") + url = "https://api.siliconflow.cn/v1/chat/completions" # 获取用户总目标 @@ -2139,137 +2482,127 @@ class GmailIntegration: return [] def handle_auth_code(self, auth_code): - """ - 处理授权码并完成OAuth2授权流程,使用与quickstart.py相同的简化流程 - - Args: - auth_code (str): 从Google授权页面获取的授权码 - - Returns: - bool: 授权是否成功 - """ + """处理OAuth2回调授权码""" try: - logger.info("开始处理Gmail授权码...") + flow = self.get_oauth_flow() + flow.fetch_token(code=auth_code) + self.credentials = flow.credentials - # 确保client_secret_json已提供 - if not self.client_secret: - logger.error("未提供client_secret_json,无法处理授权码") - return False - - # 创建临时文件存储client_secret - client_secret_path = 'client_secret.json' - with open(client_secret_path, 'w') as f: - if isinstance(self.client_secret, str): - logger.info("client_secret是字符串,解析为JSON") - try: - # 确保是有效的JSON - json_data = json.loads(self.client_secret) - # 强制设置redirect_uris为非浏览器模式,避免localhost连接拒绝问题 - for key in ['web', 'installed']: - if key in json_data and 'redirect_uris' in json_data[key]: - json_data[key]['redirect_uris'] = ['urn:ietf:wg:oauth:2.0:oob'] - logger.info("已强制设置redirect_uri为非浏览器模式") - json.dump(json_data, f) - except json.JSONDecodeError as e: - logger.error(f"client_secret不是有效的JSON: {str(e)}") - return False - else: - logger.info("client_secret是字典,直接写入文件") - # 如果是字典,也进行相同处理 - client_secret_dict = dict(self.client_secret) - for key in ['web', 'installed']: - if key in client_secret_dict and 'redirect_uris' in client_secret_dict[key]: - client_secret_dict[key]['redirect_uris'] = ['urn:ietf:wg:oauth:2.0:oob'] - logger.info("已强制设置redirect_uri为非浏览器模式") - json.dump(client_secret_dict, f) - - logger.info(f"已将client_secret写入临时文件: {client_secret_path}") + # 初始化Gmail服务 + self.gmail_service = discovery.build('gmail', 'v1', http=self.credentials.authorize(Http())) + # 获取Gmail账号 + gmail_email = None try: - # 确认token目录存在 - token_dir = os.path.dirname(self.token_storage_path) - if token_dir and not os.path.exists(token_dir): - logger.info(f"创建token目录: {token_dir}") - os.makedirs(token_dir) + # 调用API获取用户资料 + profile = self.gmail_service.users().getProfile(userId='me').execute() + gmail_email = profile.get('emailAddress') + logger.info(f"获取到Gmail账号: {gmail_email}") + except Exception as e: + logger.error(f"获取Gmail账号失败: {str(e)}") - # 设置token存储 - logger.info(f"设置token存储: {self.token_storage_path}") - store = file.Storage(self.token_storage_path) - - # 强制使用非浏览器认证模式 - redirect_uri = 'urn:ietf:wg:oauth:2.0:oob' - logger.info(f"使用非浏览器认证模式: {redirect_uri}") - - # 从client_secret创建flow - logger.info("从client_secret创建授权流程") - flow = client.flow_from_clientsecrets( - client_secret_path, - self.SCOPES, - redirect_uri=redirect_uri - ) - - # 使用授权码交换token - logger.info("使用授权码交换访问令牌") - credentials = flow.step2_exchange(auth_code) - logger.info("成功获取到访问令牌") - - # 保存到文件 - logger.info(f"保存凭证到文件: {self.token_storage_path}") - store.put(credentials) - - # 保存到实例变量 - self.credentials = credentials - - # 初始化Gmail服务 - logger.info("初始化Gmail服务") - self.gmail_service = discovery.build('gmail', 'v1', http=credentials.authorize(Http())) - logger.info("Gmail服务初始化成功") - - # 保存到数据库 + # 保存凭证到数据库 + if self.user: from django.utils import timezone - logger.info("保存凭证到数据库") # 将凭证对象序列化 - credentials_data = pickle.dumps(credentials) + credentials_data = pickle.dumps(self.credentials) - gmail_credential, created = GmailCredential.objects.update_or_create( - user=self.user, - defaults={ - 'credentials': credentials_data, - 'token_path': self.token_storage_path, - 'updated_at': timezone.now(), - 'is_active': True - } + # 如果提供了具体的gmail_credential_id,更新对应的记录 + if self.gmail_credential_id: + try: + gmail_credential = GmailCredential.objects.get( + id=self.gmail_credential_id, + user=self.user + ) + + # 更新凭证信息 + gmail_credential.credentials = credentials_data + gmail_credential.gmail_email = gmail_email + gmail_credential.token_path = self.token_storage_path + gmail_credential.updated_at = timezone.now() + gmail_credential.is_active = True + gmail_credential.save() + + self.gmail_credential = gmail_credential + logger.info(f"已更新ID为 {self.gmail_credential_id} 的Gmail凭证,Gmail账号: {gmail_email}") + except GmailCredential.DoesNotExist: + logger.error(f"未找到ID为 {self.gmail_credential_id} 的Gmail凭证,将创建新凭证") + self.gmail_credential_id = None + + # 如果没有具体的gmail_credential_id,或者指定的不存在,创建或更新默认凭证 + if not self.gmail_credential_id: + # 检查是否已存在相同gmail_email的凭证 + existing_credential = None + if gmail_email: + existing_credential = GmailCredential.objects.filter( + user=self.user, + gmail_email=gmail_email, + is_active=True + ).first() + + if existing_credential: + # 更新现有凭证 + existing_credential.credentials = credentials_data + existing_credential.token_path = self.token_storage_path + existing_credential.updated_at = timezone.now() + existing_credential.save() + + self.gmail_credential = existing_credential + logger.info(f"已更新Gmail账号 {gmail_email} 的现有凭证") + else: + # 获取Gmail账户数量 + gmail_count = GmailCredential.objects.filter(user=self.user).count() + + # 创建新凭证 + name = f"Gmail账号 {gmail_count + 1}" + if gmail_email: + name = gmail_email + else: + # 确保gmail_email有默认值,避免null错误 + gmail_email = "未知邮箱" + + # 将凭证转换为JSON字符串 + if isinstance(credentials_data, dict): + # 确保json模块在本地作用域可访问 + import json + credentials_data = json.dumps(credentials_data) + + gmail_credential = GmailCredential.objects.create( + user=self.user, + credentials=credentials_data, + token_path=self.token_storage_path, + gmail_email=gmail_email, + name=name, + is_default=(gmail_count == 0), # 第一个账号设为默认 + updated_at=timezone.now(), + is_active=True + ) + + self.gmail_credential = gmail_credential + logger.info(f"已创建新的Gmail凭证,Gmail账号: {gmail_email}, 名称: {name}") + + # 更新单例 + GmailServiceManager.update_instance( + self.user, + self.credentials, + self.gmail_service, + self.gmail_credential ) - - action = "创建" if created else "更新" - logger.info(f"已{action}用户 {self.user.username} 的Gmail凭证记录") - - # 成功获取凭证后更新单例 - if self.user and self.gmail_service and self.credentials: - GmailServiceManager.update_instance(self.user, self.credentials, self.gmail_service) - - return True - - except client.FlowExchangeError as e: - logger.error(f"授权码交换失败: {str(e)}") - return False - except Exception as e: - logger.error(f"处理授权码时发生错误: {str(e)}") - import traceback - logger.error(traceback.format_exc()) - return False - finally: - # 删除临时文件 - if os.path.exists(client_secret_path): - logger.info(f"删除临时文件: {client_secret_path}") - os.unlink(client_secret_path) - + + return { + 'status': 'success', + 'gmail_email': gmail_email + } + except Exception as e: - logger.error(f"处理授权码过程中发生异常: {str(e)}") + logger.error(f"处理授权码失败: {str(e)}") import traceback logger.error(traceback.format_exc()) - return False + return { + 'status': 'error', + 'message': str(e) + } def _upload_attachments_to_knowledge_base(self, knowledge_base, conversations): """上传所有邮件附件到知识库""" @@ -2519,7 +2852,7 @@ class GmailIntegration: def get_recent_emails(self, from_email=None, max_results=10): """获取最近的邮件""" try: - service = self.service + service = self.gmail_service if not service: logger.error("Gmail服务未初始化") return [] @@ -2722,13 +3055,25 @@ class GmailIntegration: """调用DeepSeek API生成对话总结""" try: # 使用有效的API密钥 - api_key = "sk-xqbujijjqqmlmlvkhvxeogqjtzslnhdtqxqgiyuhwpoqcjvf" - # 如果上面的密钥不正确,可以尝试从环境变量或数据库中获取 + api_key = "" + # 尝试从环境变量获取 + import os + from dotenv import load_dotenv + load_dotenv() + env_api_key = os.environ.get('DEEPSEEK_API_KEY') + if env_api_key: + api_key = env_api_key + # 从Django设置中获取密钥 from django.conf import settings if hasattr(settings, 'DEEPSEEK_API_KEY') and settings.DEEPSEEK_API_KEY: api_key = settings.DEEPSEEK_API_KEY + # 如果仍然没有有效的API密钥,使用默认值 + if not api_key: + api_key = "sk-xqbujijjqqmlmlvkhvxeogqjtzslnhdtqxqgiyuhwpoqcjvf" + logger.warning("使用默认API密钥,请在环境变量或settings.py中设置DEEPSEEK_API_KEY") + url = "https://api.siliconflow.cn/v1/chat/completions" # 系统消息指定生成总结的任务 @@ -2807,3 +3152,498 @@ class GmailIntegration: logger.error(f"调用DeepSeek API生成总结失败: {str(e)}") logger.error(traceback.format_exc()) return None + + def get_oauth_flow(self): + """创建OAuth2流程处理器""" + try: + # 确保client_secret_json已提供 + if not self.client_secret: + logger.error("未提供client_secret_json,无法创建OAuth流程") + raise ValueError("未提供client_secret_json") + + # 创建临时文件存储client_secret + client_secret_path = 'client_secret.json' + with open(client_secret_path, 'w') as f: + if isinstance(self.client_secret, str): + try: + # 确保是有效的JSON + import json # 在本地作用域引入 + json_data = json.loads(self.client_secret) + # 强制设置redirect_uris为非浏览器模式,避免localhost连接拒绝问题 + for key in ['web', 'installed']: + if key in json_data and 'redirect_uris' in json_data[key]: + json_data[key]['redirect_uris'] = ['urn:ietf:wg:oauth:2.0:oob'] + logger.info("已强制设置redirect_uri为非浏览器模式") + json.dump(json_data, f) + except json.JSONDecodeError as e: + logger.error(f"client_secret不是有效的JSON: {str(e)}") + raise ValueError(f"client_secret不是有效的JSON: {str(e)}") + else: + # 如果是字典,也进行相同处理 + import json # 在本地作用域引入 + client_secret_dict = dict(self.client_secret) + for key in ['web', 'installed']: + if key in client_secret_dict and 'redirect_uris' in client_secret_dict[key]: + client_secret_dict[key]['redirect_uris'] = ['urn:ietf:wg:oauth:2.0:oob'] + logger.info("已强制设置redirect_uri为非浏览器模式") + json.dump(client_secret_dict, f) + + # 从client_secret创建flow + flow = client.flow_from_clientsecrets( + client_secret_path, + self.SCOPES, + redirect_uri='urn:ietf:wg:oauth:2.0:oob' + ) + + return flow + + except Exception as e: + logger.error(f"创建OAuth流程失败: {str(e)}") + logger.error(traceback.format_exc()) + raise e + finally: + # 删除临时文件 + if os.path.exists(client_secret_path): + try: + os.unlink(client_secret_path) + except Exception as del_e: + logger.error(f"删除临时文件失败: {str(del_e)}") + + def check_and_renew_watch(self): + """检查并更新Gmail监听状态 + + 如果监听即将过期或已经过期,则自动更新监听 + """ + try: + from .models import GmailCredential + + # 获取用户的Gmail凭证 + credential = GmailCredential.objects.filter( + user=self.user, + is_active=True, + gmail_credential_id=self.gmail_credential_id if self.gmail_credential_id else None + ).first() + + if not credential: + logger.error(f"找不到用户 {self.user.email} 的Gmail凭证") + return False + + # 检查监听是否需要更新 + needs_renewal = False + + # 如果没有watch_expiration,需要设置监听 + if not credential.watch_expiration: + logger.info(f"Gmail凭证 {credential.gmail_email} 没有监听过期时间,需要设置监听") + needs_renewal = True + else: + # 如果监听将在24小时内过期,更新监听 + now = timezone.now() if hasattr(settings, 'USE_TZ') and settings.USE_TZ else datetime.now() + time_until_expiration = credential.watch_expiration - now + hours_until_expiration = time_until_expiration.total_seconds() / 3600 + + if hours_until_expiration < 24: + logger.info(f"Gmail监听将在 {hours_until_expiration:.2f} 小时后过期,需要更新") + needs_renewal = True + + if needs_renewal: + logger.info(f"为Gmail凭证 {credential.gmail_email} 更新监听") + watch_result = self.setup_watch() + + if watch_result and 'historyId' in watch_result: + logger.info(f"Gmail监听更新成功: historyId={watch_result['historyId']}") + return True + else: + logger.error("Gmail监听更新失败") + return False + else: + logger.info(f"Gmail监听状态良好,无需更新。过期时间: {credential.watch_expiration}") + return True + + except Exception as e: + logger.error(f"检查和更新Gmail监听失败: {str(e)}") + logger.error(traceback.format_exc()) + return False + + def verify_connectivity(self): + """验证与Gmail API的连接性 + + 测试Gmail API连接,并返回连接状态 + + Returns: + dict: 包含连接测试结果的信息 + """ + try: + if not hasattr(self, 'gmail_service') or not self.gmail_service: + logger.warning("Gmail服务未初始化,尝试认证") + if not self.authenticate(): + return { + 'status': 'error', + 'message': 'Gmail认证失败', + 'is_connected': False + } + + # 尝试获取用户的个人资料,这是一个轻量级的API调用 + profile = self.gmail_service.users().getProfile(userId='me').execute() + + # 检查是否有必要的监听信息 + from .models import GmailCredential + credential = GmailCredential.objects.filter( + user=self.user, + is_active=True, + gmail_credential_id=self.gmail_credential_id if self.gmail_credential_id else None + ).first() + + watch_info = {} + if credential: + watch_info = { + 'has_watch': credential.watch_expiration is not None, + 'watch_expiration': credential.watch_expiration.strftime('%Y-%m-%d %H:%M:%S') if credential.watch_expiration else None, + 'last_history_id': credential.last_history_id + } + + return { + 'status': 'success', + 'message': 'Gmail连接正常', + 'is_connected': True, + 'profile': { + 'email': profile.get('emailAddress'), + 'messages_total': profile.get('messagesTotal'), + 'threads_total': profile.get('threadsTotal') + }, + 'watch_info': watch_info + } + + except Exception as e: + logger.error(f"Gmail连接测试失败: {str(e)}") + logger.error(traceback.format_exc()) + + error_message = str(e) + suggestions = [] + + if "invalid_grant" in error_message.lower(): + suggestions.append("OAuth令牌已过期,需要重新授权") + elif "401" in error_message: + suggestions.append("认证失败,请重新登录Gmail账号") + elif "EOF occurred in violation of protocol" in error_message: + suggestions.append("SSL连接问题,可能是网络或代理配置问题") + elif "connect timeout" in error_message.lower() or "connection timeout" in error_message.lower(): + suggestions.append("连接超时,请检查网络连接和代理设置") + + return { + 'status': 'error', + 'message': f'Gmail连接失败: {error_message}', + 'is_connected': False, + 'suggestions': suggestions + } + + @classmethod + def batch_renew_watches(cls): + """ + 批量检查和更新所有用户的Gmail监听状态 + + 此方法可以由定时任务调用,确保所有用户的Gmail监听都保持活跃状态 + + Returns: + dict: 包含批处理结果的信息 + """ + try: + from .models import GmailCredential, User + import time + + # 获取所有活跃的Gmail凭证 + now = timezone.now() if hasattr(settings, 'USE_TZ') and settings.USE_TZ else datetime.now() + + # 计算24小时后的时间 + expiration_threshold = now + timedelta(hours=24) + + # 获取即将过期或没有监听的凭证 + credentials_to_update = GmailCredential.objects.filter( + is_active=True + ).filter( + Q(watch_expiration__lt=expiration_threshold) | + Q(watch_expiration__isnull=True) + ) + + logger.info(f"找到 {credentials_to_update.count()} 个需要更新监听的Gmail凭证") + + success_count = 0 + failure_count = 0 + + # 依次处理每个凭证 + for credential in credentials_to_update: + try: + user = credential.user + if not user or not user.is_active: + logger.warning(f"跳过已停用用户的凭证: {credential.id}") + continue + + logger.info(f"处理用户 {user.email} 的Gmail凭证 {credential.gmail_email}") + + # 创建Gmail集成实例 + gmail = cls(user, gmail_credential_id=credential.id) + + # 认证 + if not gmail.authenticate(): + logger.error(f"用户 {user.email} 的Gmail认证失败") + failure_count += 1 + continue + + # 更新监听 + result = gmail.setup_watch() + if result and 'historyId' in result: + logger.info(f"成功更新用户 {user.email} 的Gmail监听") + success_count += 1 + else: + logger.error(f"更新用户 {user.email} 的Gmail监听失败") + failure_count += 1 + + # 休眠一小段时间,避免请求过于频繁 + time.sleep(1) + + except Exception as e: + failure_count += 1 + logger.error(f"处理凭证 {credential.id} 时出错: {str(e)}") + logger.error(traceback.format_exc()) + + return { + 'status': 'success', + 'message': f'批量更新Gmail监听完成', + 'total_processed': credentials_to_update.count(), + 'success_count': success_count, + 'failure_count': failure_count + } + + except Exception as e: + logger.error(f"批量更新Gmail监听失败: {str(e)}") + logger.error(traceback.format_exc()) + return { + 'status': 'error', + 'message': f'批量更新Gmail监听失败: {str(e)}' + } + + def refresh_token(self): + """ + 尝试刷新OAuth令牌,如果失败则标记凭证需要重新授权 + + Returns: + bool: 刷新是否成功 + """ + try: + logger.info(f"尝试刷新用户 {self.user.email} 的OAuth令牌") + + # 清除当前服务实例 + GmailServiceManager.clear_instance(self.user, self.gmail_credential_id) + + # 尝试重新认证 + if not self.authenticate(): + logger.error("重新认证失败") + self._mark_credential_needs_reauth() + return False + + # 验证新的令牌是否有效 + try: + # 设置超时 + original_timeout = socket.getdefaulttimeout() + socket.setdefaulttimeout(GMAIL_REQUEST_TIMEOUT) + + try: + # 尝试一个简单的API调用来验证 + profile = self.gmail_service.users().getProfile(userId='me').execute() + logger.info(f"令牌刷新成功,已验证: {profile.get('emailAddress')}") + return True + except Exception as e: + error_msg = str(e) + if "invalid_grant" in error_msg.lower() or "401" in error_msg: + logger.error(f"令牌仍然无效: {error_msg}") + self._mark_credential_needs_reauth() + return False + else: + # 其他错误,但令牌可能是有效的 + logger.warning(f"令牌可能有效,但API调用失败: {error_msg}") + return True + finally: + # 恢复原始超时设置 + socket.setdefaulttimeout(original_timeout) + except Exception as e: + logger.error(f"验证新令牌时出错: {str(e)}") + return False + + except Exception as e: + logger.error(f"刷新令牌失败: {str(e)}") + logger.error(traceback.format_exc()) + self._mark_credential_needs_reauth() + return False + + def _mark_credential_needs_reauth(self): + """标记凭证需要重新授权""" + try: + from .models import GmailCredential + + # 查找当前凭证 + credential = GmailCredential.objects.filter( + user=self.user, + id=self.gmail_credential_id if self.gmail_credential_id else None, + is_active=True + ).first() + + if credential: + # 标记需要重新授权 + credential.needs_reauth = True + credential.save() + + logger.info(f"已标记Gmail凭证 {credential.id} 需要重新授权") + + # 从缓存中移除服务实例 + GmailServiceManager.clear_instance(self.user, self.gmail_credential_id) + except Exception as e: + logger.error(f"标记凭证需要重新授权失败: {str(e)}") + + @classmethod + def process_queued_notifications(cls, user=None): + """ + 处理队列中的通知,可以在用户重新授权后调用 + + Args: + user: 可选的用户参数,如果提供,只处理该用户的通知 + + Returns: + dict: 处理结果统计 + """ + try: + from .models import GmailNotificationQueue, GmailCredential + import json + + # 构建查询 + query = Q(processed=False) + if user: + query &= Q(user=user) + + # 获取未处理的通知 + queued_notifications = GmailNotificationQueue.objects.filter(query) + + logger.info(f"找到 {queued_notifications.count()} 条未处理的Gmail通知") + + processed_count = 0 + success_count = 0 + + # 按用户ID和Gmail凭证ID分组处理 + from django.db.models import Count + user_creds = queued_notifications.values('user_id', 'gmail_credential_id').annotate( + count=Count('id') + ).order_by('user_id', 'gmail_credential_id') + + for user_cred in user_creds: + user_id = user_cred['user_id'] + cred_id = user_cred['gmail_credential_id'] + + # 获取凭证信息 + credential = GmailCredential.objects.filter(id=cred_id, is_active=True).first() + if not credential or credential.needs_reauth: + logger.warning(f"凭证 {cred_id} 需要重新授权,跳过相关通知") + continue + + # 获取用户对象 + from .models import User + user_obj = User.objects.get(id=user_id) + + # 初始化Gmail集成 + gmail_integration = cls(user_obj, gmail_credential_id=cred_id) + + # 获取用户的队列通知 + user_notifications = queued_notifications.filter( + user_id=user_id, + gmail_credential_id=cred_id + ).order_by('created_at') # 按创建时间排序 + + for notification in user_notifications: + try: + # 解析通知数据 + try: + notification_data = json.loads(notification.notification_data) + except: + notification_data = { + 'emailAddress': notification.email, + 'historyId': notification.history_id + } + + # 处理通知 + result = gmail_integration.process_notification(notification_data) + + # 更新处理状态 + notification.processed = True + notification.success = result + notification.processed_at = timezone.now() + notification.save() + + processed_count += 1 + if result: + success_count += 1 + + except Exception as e: + logger.error(f"处理队列通知 {notification.id} 失败: {str(e)}") + + # 标记处理失败 + notification.processed = True + notification.success = False + notification.error_message = str(e)[:255] # 截断错误消息 + notification.processed_at = timezone.now() + notification.save() + + processed_count += 1 + + return { + 'status': 'success', + 'total_processed': processed_count, + 'success_count': success_count, + 'remaining': queued_notifications.count() - processed_count + } + + except Exception as e: + logger.error(f"处理队列通知失败: {str(e)}") + logger.error(traceback.format_exc()) + return { + 'status': 'error', + 'message': str(e) + } + + def _load_credentials_from_storage(self, credential_data): + """从存储中加载凭证,处理可能的格式不匹配问题""" + logger.info("尝试加载凭证数据") + + if not credential_data: + logger.error("凭证数据为空") + return None + + # 方法1: 尝试直接JSON解析 + try: + import json + if isinstance(credential_data, str): + cred_json = credential_data + else: + cred_json = credential_data.decode('utf-8') + + # 验证是否为有效JSON + json.loads(cred_json) + + # 使用OAuth2Credentials从JSON创建凭证 + logger.info("成功从JSON创建凭证") + return client.OAuth2Credentials.from_json(cred_json) + except Exception as json_error: + logger.error(f"JSON解析凭证失败: {str(json_error)}") + + # 方法2: 尝试pickle解析 + try: + if isinstance(credential_data, str): + # 如果是字符串,尝试编码为二进制 + pickle_data = credential_data.encode('latin1') + else: + pickle_data = credential_data + + logger.info("尝试使用pickle解析凭证") + return pickle.loads(pickle_data) + except Exception as pickle_error: + logger.error(f"Pickle解析凭证失败: {str(pickle_error)}") + + # 所有方法都失败 + logger.error("所有凭证解析方法都失败") + return None diff --git a/user_management/management/__init__.py b/user_management/management/__init__.py index 31bbb3b..a213c77 100644 --- a/user_management/management/__init__.py +++ b/user_management/management/__init__.py @@ -1 +1,3 @@ # 管理命令包 + +# 命令模块初始化 diff --git a/user_management/management/commands/__init__.py b/user_management/management/commands/__init__.py index e266d78..a91482f 100644 --- a/user_management/management/commands/__init__.py +++ b/user_management/management/commands/__init__.py @@ -1 +1,3 @@ # Gmail管理命令 + +# 命令模块初始化 diff --git a/user_management/management/commands/fix_gmail_credentials.py b/user_management/management/commands/fix_gmail_credentials.py new file mode 100644 index 0000000..dda4aa4 --- /dev/null +++ b/user_management/management/commands/fix_gmail_credentials.py @@ -0,0 +1,64 @@ +from django.core.management.base import BaseCommand +import logging +import pickle +import json +from user_management.models import GmailCredential +from oauth2client import client + +logger = logging.getLogger(__name__) + +class Command(BaseCommand): + help = '修复Gmail凭证数据格式问题' + + def handle(self, *args, **options): + self.stdout.write(self.style.SUCCESS('开始修复Gmail凭证...')) + credentials = GmailCredential.objects.all() + + fixed_count = 0 + error_count = 0 + + for cred in credentials: + try: + if not cred.credentials: + self.stdout.write(self.style.WARNING(f'ID {cred.id} 的凭证为空,跳过')) + continue + + # 检测当前凭证格式 + self.stdout.write(f'处理凭证 ID: {cred.id}, 邮箱: {cred.gmail_email}') + + # 1. 尝试作为JSON加载 + try: + if isinstance(cred.credentials, str): + # 验证是否为有效JSON + json.loads(cred.credentials) + self.stdout.write(self.style.SUCCESS(f'凭证 {cred.id} 已是有效JSON格式')) + fixed_count += 1 + continue + except Exception: + pass + + # 2. 尝试从二进制pickle加载并转换为JSON + try: + # 处理可能的pickle格式 + if isinstance(cred.credentials, str): + oauth_creds = pickle.loads(cred.credentials.encode('latin1')) + else: + oauth_creds = pickle.loads(cred.credentials) + + # 转换为JSON并保存 + json_creds = oauth_creds.to_json() + cred.credentials = json_creds + cred.save() + + self.stdout.write(self.style.SUCCESS(f'凭证 {cred.id} 已从pickle转换为JSON格式')) + fixed_count += 1 + continue + except Exception as e: + self.stdout.write(self.style.ERROR(f'无法处理凭证 {cred.id}: {str(e)}')) + error_count += 1 + + except Exception as e: + self.stdout.write(self.style.ERROR(f'处理凭证 {cred.id} 时出错: {str(e)}')) + error_count += 1 + + self.stdout.write(self.style.SUCCESS(f'处理完成! 成功: {fixed_count}, 失败: {error_count}')) \ No newline at end of file diff --git a/user_management/management/commands/publish_video.py b/user_management/management/commands/publish_video.py new file mode 100644 index 0000000..2f48d71 --- /dev/null +++ b/user_management/management/commands/publish_video.py @@ -0,0 +1,49 @@ +from django.core.management.base import BaseCommand +from user_management.models import Video +from user_management.tasks import publish_scheduled_video + +class Command(BaseCommand): + help = '手动发布视频' + + def add_arguments(self, parser): + parser.add_argument('video_id', type=int, help='视频ID') + + def handle(self, *args, **options): + video_id = options['video_id'] + + try: + # 获取视频对象 + video = Video.objects.get(id=video_id) + except Video.DoesNotExist: + self.stderr.write(self.style.ERROR(f'错误: 未找到ID为{video_id}的视频')) + return + + # 检查视频状态是否允许发布 + if video.status not in ['draft', 'scheduled']: + self.stderr.write(self.style.ERROR( + f'错误: 当前视频状态为 {video.get_status_display()},无法发布' + )) + return + + self.stdout.write(f'开始发布视频 "{video.title}" (ID: {video.id})...') + + # 执行发布任务 + try: + result = publish_scheduled_video(video.id) + + if isinstance(result, dict) and result.get('success', False): + self.stdout.write(self.style.SUCCESS( + f'视频发布成功!\n' + f'标题: {video.title}\n' + f'平台: {video.platform_account.get_platform_name_display()}\n' + f'账号: {video.platform_account.account_name}\n' + f'视频链接: {result.get("video_url")}\n' + f'发布时间: {result.get("publish_time")}' + )) + else: + self.stderr.write(self.style.ERROR( + f'发布失败: {result.get("error", "未知错误")}' + )) + + except Exception as e: + self.stderr.write(self.style.ERROR(f'发布过程中出错: {str(e)}')) \ No newline at end of file diff --git a/user_management/management/commands/test_video_upload.py b/user_management/management/commands/test_video_upload.py new file mode 100644 index 0000000..6db303e --- /dev/null +++ b/user_management/management/commands/test_video_upload.py @@ -0,0 +1,142 @@ +import os +import datetime +from django.core.management.base import BaseCommand +from django.utils import timezone +from user_management.models import PlatformAccount, Video +from django.conf import settings + +class Command(BaseCommand): + help = '测试视频上传和定时发布功能' + + def add_arguments(self, parser): + parser.add_argument('video_path', type=str, help='视频文件路径') + parser.add_argument('platform_account_id', type=int, help='平台账号ID') + parser.add_argument('--title', type=str, help='视频标题(可选)') + parser.add_argument('--desc', type=str, help='视频描述(可选)') + parser.add_argument('--schedule', type=str, help='计划发布时间,格式: YYYY-MM-DD HH:MM:SS (可选)') + + def handle(self, *args, **options): + video_path = options['video_path'] + platform_account_id = options['platform_account_id'] + title = options.get('title') + desc = options.get('desc') + schedule_str = options.get('schedule') + + # 验证视频文件是否存在 + if not os.path.exists(video_path): + self.stderr.write(self.style.ERROR(f'错误: 视频文件不存在: {video_path}')) + return + + # 验证平台账号是否存在 + try: + platform_account = PlatformAccount.objects.get(id=platform_account_id) + except PlatformAccount.DoesNotExist: + self.stderr.write(self.style.ERROR(f'错误: 未找到ID为{platform_account_id}的平台账号')) + return + + # 设置标题(如果未提供,则使用文件名) + if not title: + title = os.path.splitext(os.path.basename(video_path))[0] + + # 准备保存视频的目录 + media_root = getattr(settings, 'MEDIA_ROOT', os.path.join(settings.BASE_DIR, 'media')) + videos_dir = os.path.join(media_root, 'videos') + account_dir = os.path.join(videos_dir, f"{platform_account.platform_name}_{platform_account.account_name}") + + if not os.path.exists(videos_dir): + os.makedirs(videos_dir) + if not os.path.exists(account_dir): + os.makedirs(account_dir) + + # 生成唯一的文件名 + import time + timestamp = int(time.time()) + file_name = f"{timestamp}_{os.path.basename(video_path)}" + file_path = os.path.join(account_dir, file_name) + + # 复制视频文件 + with open(video_path, 'rb') as src_file: + with open(file_path, 'wb') as dest_file: + dest_file.write(src_file.read()) + + self.stdout.write(self.style.SUCCESS(f'视频文件已复制到: {file_path}')) + + # 创建视频记录 + video_data = { + 'platform_account': platform_account, + 'title': title, + 'description': desc, + 'local_path': file_path, + 'status': 'draft', + } + + # 处理计划发布时间 + if schedule_str: + try: + from dateutil import parser + scheduled_time = parser.parse(schedule_str) + + # 如果时间已过,设置为当前时间后5分钟 + now = timezone.now() + if scheduled_time <= now: + scheduled_time = now + datetime.timedelta(minutes=5) + self.stdout.write(self.style.WARNING( + f'警告: 计划时间已过,已调整为当前时间后5分钟: {scheduled_time}' + )) + + video_data['scheduled_time'] = scheduled_time + video_data['status'] = 'scheduled' + + except Exception as e: + self.stderr.write(self.style.ERROR(f'错误: 解析时间失败: {str(e)}')) + return + + # 创建视频对象 + video = Video.objects.create(**video_data) + + self.stdout.write(self.style.SUCCESS(f'创建视频记录成功,ID: {video.id}')) + + # 如果是计划发布,创建定时任务 + if video.status == 'scheduled': + try: + from django_celery_beat.models import PeriodicTask, CrontabSchedule + import json + + 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}_{time.time()}" + 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 + ) + + self.stdout.write(self.style.SUCCESS( + f'创建定时发布任务成功,计划发布时间: {scheduled_time}' + )) + + except Exception as e: + self.stderr.write(self.style.ERROR(f'创建定时任务失败: {str(e)}')) + + self.stdout.write(self.style.SUCCESS('操作完成')) + + # 打印执行方式提示 + if video.status == 'scheduled': + self.stdout.write(f"\n视频将在 {video.scheduled_time} 自动发布") + self.stdout.write("\n要手动发布,可以使用以下命令:") + else: + self.stdout.write("\n要发布该视频,可以使用以下命令:") + + self.stdout.write(f"python manage.py publish_video {video.id}") \ No newline at end of file diff --git a/user_management/migrations/0001_chathistory_title_gmailcredential_gmail_email_and_more.py b/user_management/migrations/0001_chathistory_title_gmailcredential_gmail_email_and_more.py new file mode 100644 index 0000000..e9940e6 --- /dev/null +++ b/user_management/migrations/0001_chathistory_title_gmailcredential_gmail_email_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.5 on 2025-04-23 03:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', 'fix_gmail_email_field'), + ] + + operations = [ + migrations.AddField( + model_name='chathistory', + name='title', + field=models.CharField(blank=True, default='New chat', help_text='对话标题', max_length=100, null=True), + ), + migrations.AddField( + model_name='gmailcredential', + name='gmail_email', + field=models.EmailField(blank=True, help_text='实际授权的Gmail账号,可能与user.email不同', max_length=255, null=True), + ), + migrations.AddField( + model_name='gmailcredential', + name='is_default', + field=models.BooleanField(default=False, help_text='是否为默认Gmail账号'), + ), + migrations.AddField( + model_name='gmailcredential', + name='name', + field=models.CharField(default='默认Gmail', help_text='此Gmail账号的自定义名称', max_length=100), + ), + ] diff --git a/user_management/migrations/0002_knowledgebasedocument_uploader_name.py b/user_management/migrations/0002_knowledgebasedocument_uploader_name.py new file mode 100644 index 0000000..40d1485 --- /dev/null +++ b/user_management/migrations/0002_knowledgebasedocument_uploader_name.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-04-23 08:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0001_chathistory_title_gmailcredential_gmail_email_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='knowledgebasedocument', + name='uploader_name', + field=models.CharField(default='未知用户', max_length=100, verbose_name='上传者姓名'), + ), + ] diff --git a/user_management/migrations/0003_operatoraccount_platformaccount_video.py b/user_management/migrations/0003_operatoraccount_platformaccount_video.py new file mode 100644 index 0000000..dd5f96e --- /dev/null +++ b/user_management/migrations/0003_operatoraccount_platformaccount_video.py @@ -0,0 +1,82 @@ +# Generated by Django 5.1.5 on 2025-04-26 02:47 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0002_knowledgebasedocument_uploader_name'), + ] + + operations = [ + migrations.CreateModel( + name='OperatorAccount', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('username', models.CharField(max_length=100, unique=True, verbose_name='用户名')), + ('password', models.CharField(max_length=255, verbose_name='密码')), + ('real_name', models.CharField(max_length=50, verbose_name='真实姓名')), + ('email', models.EmailField(max_length=254, verbose_name='邮箱')), + ('phone', models.CharField(max_length=15, verbose_name='电话')), + ('position', models.CharField(choices=[('editor', '编辑'), ('planner', '策划'), ('operator', '运营'), ('admin', '管理员')], max_length=20, verbose_name='工作定位')), + ('department', models.CharField(max_length=50, verbose_name='部门')), + ('is_active', 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='更新时间')), + ], + options={ + 'verbose_name': '运营账号', + 'verbose_name_plural': '运营账号', + }, + ), + migrations.CreateModel( + name='PlatformAccount', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('platform_name', models.CharField(choices=[('youtube', 'YouTube'), ('tiktok', 'TikTok'), ('twitter', 'Twitter/X'), ('instagram', 'Instagram'), ('facebook', 'Facebook'), ('bilibili', 'Bilibili')], max_length=20, verbose_name='平台名称')), + ('account_name', models.CharField(max_length=100, verbose_name='账号名称')), + ('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='账号链接')), + ('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='更新时间')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='最后登录时间')), + ('operator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='platform_accounts', to='user_management.operatoraccount', verbose_name='关联运营')), + ], + options={ + 'verbose_name': '平台账号', + 'verbose_name_plural': '平台账号', + 'unique_together': {('platform_name', 'account_id')}, + }, + ), + migrations.CreateModel( + name='Video', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200, verbose_name='视频标题')), + ('description', models.TextField(blank=True, null=True, verbose_name='视频描述')), + ('video_url', models.URLField(blank=True, null=True, verbose_name='视频地址')), + ('local_path', models.CharField(blank=True, max_length=255, null=True, verbose_name='本地路径')), + ('thumbnail_url', models.URLField(blank=True, null=True, verbose_name='缩略图地址')), + ('status', models.CharField(choices=[('draft', '草稿'), ('scheduled', '已排期'), ('published', '已发布'), ('failed', '发布失败'), ('deleted', '已删除')], default='draft', max_length=20, verbose_name='发布状态')), + ('views_count', models.IntegerField(default=0, verbose_name='播放次数')), + ('likes_count', models.IntegerField(default=0, verbose_name='点赞数')), + ('comments_count', models.IntegerField(default=0, verbose_name='评论数')), + ('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='计划发布时间')), + ('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='user_management.platformaccount', verbose_name='发布账号')), + ], + options={ + 'verbose_name': '视频', + 'verbose_name_plural': '视频', + }, + ), + ] diff --git a/user_management/migrations/0004_operatoraccount_uuid_alter_operatoraccount_id.py b/user_management/migrations/0004_operatoraccount_uuid_alter_operatoraccount_id.py new file mode 100644 index 0000000..79f12c9 --- /dev/null +++ b/user_management/migrations/0004_operatoraccount_uuid_alter_operatoraccount_id.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.5 on 2025-04-27 03:14 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0003_operatoraccount_platformaccount_video'), + ] + + operations = [ + migrations.AddField( + model_name='operatoraccount', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID'), + ), + migrations.AlterField( + model_name='operatoraccount', + name='id', + field=models.AutoField(primary_key=True, serialize=False), + ), + ] diff --git a/user_management/migrations/0005_alter_gmailcredential_options_and_more.py b/user_management/migrations/0005_alter_gmailcredential_options_and_more.py new file mode 100644 index 0000000..436ef44 --- /dev/null +++ b/user_management/migrations/0005_alter_gmailcredential_options_and_more.py @@ -0,0 +1,113 @@ +# Generated by Django 5.1.5 on 2025-04-28 07:12 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0004_operatoraccount_uuid_alter_operatoraccount_id'), + ] + + operations = [ + migrations.AlterModelOptions( + name='gmailcredential', + options={'ordering': ['-is_default', '-updated_at'], 'verbose_name': 'Gmail凭证', 'verbose_name_plural': 'Gmail凭证'}, + ), + migrations.AlterUniqueTogether( + name='gmailcredential', + unique_together={('user', 'gmail_email')}, + ), + migrations.AddField( + model_name='gmailcredential', + name='credentials_json', + field=models.TextField(blank=True, null=True, verbose_name='凭证JSON'), + ), + migrations.AddField( + model_name='gmailcredential', + name='gmail_credential_id', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Gmail凭证ID'), + ), + migrations.AddField( + model_name='gmailcredential', + name='needs_reauth', + field=models.BooleanField(default=False, verbose_name='需要重新授权'), + ), + migrations.AlterField( + model_name='gmailcredential', + name='created_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'), + ), + migrations.AlterField( + model_name='gmailcredential', + name='gmail_email', + field=models.EmailField(default='your_default_email@example.com', max_length=255, verbose_name='Gmail邮箱'), + ), + migrations.AlterField( + model_name='gmailcredential', + name='is_active', + field=models.BooleanField(default=True, verbose_name='是否活跃'), + ), + migrations.AlterField( + model_name='gmailcredential', + name='is_default', + field=models.BooleanField(default=False, verbose_name='是否默认'), + ), + migrations.AlterField( + model_name='gmailcredential', + name='last_history_id', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='最后历史ID'), + ), + migrations.AlterField( + model_name='gmailcredential', + name='name', + field=models.CharField(default='默认Gmail', max_length=100, verbose_name='名称'), + ), + migrations.AlterField( + model_name='gmailcredential', + name='token_path', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='令牌路径'), + ), + migrations.AlterField( + model_name='gmailcredential', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='更新时间'), + ), + migrations.AlterField( + model_name='gmailcredential', + name='watch_expiration', + field=models.DateTimeField(blank=True, null=True, verbose_name='监听过期时间'), + ), + migrations.AlterModelTable( + name='gmailcredential', + table=None, + ), + migrations.CreateModel( + name='GmailNotificationQueue', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('email', models.EmailField(max_length=255, verbose_name='邮箱')), + ('history_id', models.CharField(max_length=100, verbose_name='历史ID')), + ('notification_data', models.TextField(blank=True, null=True, verbose_name='通知数据')), + ('processed', models.BooleanField(default=False, verbose_name='是否已处理')), + ('success', models.BooleanField(default=False, verbose_name='处理是否成功')), + ('error_message', models.CharField(blank=True, max_length=255, null=True, verbose_name='错误信息')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('processed_at', models.DateTimeField(blank=True, null=True, verbose_name='处理时间')), + ('gmail_credential', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_queue', to='user_management.gmailcredential')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gmail_notification_queue', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Gmail通知队列', + 'verbose_name_plural': 'Gmail通知队列', + 'ordering': ['processed', 'created_at'], + }, + ), + migrations.RemoveField( + model_name='gmailcredential', + name='credentials', + ), + ] diff --git a/user_management/migrations/0006_rename_credentials_json_gmailcredential_credentials.py b/user_management/migrations/0006_rename_credentials_json_gmailcredential_credentials.py new file mode 100644 index 0000000..2147e00 --- /dev/null +++ b/user_management/migrations/0006_rename_credentials_json_gmailcredential_credentials.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-04-28 07:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0005_alter_gmailcredential_options_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='gmailcredential', + old_name='credentials_json', + new_name='credentials', + ), + ] diff --git a/user_management/migrations/fix_gmail_email_field.py b/user_management/migrations/fix_gmail_email_field.py new file mode 100644 index 0000000..8a3ae72 --- /dev/null +++ b/user_management/migrations/fix_gmail_email_field.py @@ -0,0 +1,23 @@ +# Generated manually + +from django.db import migrations + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0009_gmailcredential_gmail_email_conversationsummary_and_more'), + ] + + operations = [ + # 添加name和is_default字段 + migrations.RunSQL( + sql=""" + ALTER TABLE gmail_credential ADD COLUMN name VARCHAR(100) DEFAULT '默认Gmail' NOT NULL; + ALTER TABLE gmail_credential ADD COLUMN is_default BOOLEAN DEFAULT FALSE NOT NULL; + """, + reverse_sql=""" + ALTER TABLE gmail_credential DROP COLUMN name; + ALTER TABLE gmail_credential DROP COLUMN is_default; + """ + ) + ] \ No newline at end of file diff --git a/user_management/models.py b/user_management/models.py index f07c35b..52841bd 100644 --- a/user_management/models.py +++ b/user_management/models.py @@ -291,6 +291,8 @@ class ChatHistory(models.Model): knowledge_base = models.ForeignKey('KnowledgeBase', on_delete=models.CASCADE) # 用于标识知识库组合的对话 conversation_id = models.CharField(max_length=100, db_index=True) + # 对话标题 + title = models.CharField(max_length=100, null=True, blank=True, default='New chat', help_text="对话标题") parent_id = models.CharField(max_length=100, null=True, blank=True) role = models.CharField(max_length=20, choices=ROLE_CHOICES) content = models.TextField() @@ -683,6 +685,7 @@ class KnowledgeBaseDocument(models.Model): document_id = models.CharField(max_length=100, verbose_name='文档ID') document_name = models.CharField(max_length=255, verbose_name='文档名称') external_id = models.CharField(max_length=100, verbose_name='外部文档ID') + uploader_name = models.CharField(max_length=100, default="未知用户", verbose_name='上传者姓名') status = models.CharField( max_length=20, default='active', @@ -710,23 +713,30 @@ class KnowledgeBaseDocument(models.Model): return f"{self.knowledge_base.name} - {self.document_name}" class GmailCredential(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='gmail_credentials') - gmail_email = models.EmailField(max_length=255, null=True, blank=True, help_text="实际授权的Gmail账号,可能与user.email不同") - credentials = models.BinaryField() # 序列化的凭证对象 - token_path = models.CharField(max_length=255) # token存储路径 - last_history_id = models.CharField(max_length=255, null=True, blank=True) # 最后处理的historyId - watch_expiration = models.DateTimeField(null=True, blank=True) # 监听过期时间 - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - is_active = models.BooleanField(default=True) - - class Meta: - db_table = 'gmail_credential' - + gmail_email = models.EmailField(verbose_name='Gmail邮箱', max_length=255, default='your_default_email@example.com') + name = models.CharField(verbose_name='名称', max_length=100, default='默认Gmail') + credentials = models.TextField(verbose_name='凭证JSON', blank=True, null=True) + token_path = models.CharField(verbose_name='令牌路径', max_length=255, blank=True, null=True) + is_default = models.BooleanField(verbose_name='是否默认', default=False) + last_history_id = models.CharField(verbose_name='最后历史ID', max_length=100, blank=True, null=True) + watch_expiration = models.DateTimeField(verbose_name='监听过期时间', blank=True, null=True) + is_active = models.BooleanField(verbose_name='是否活跃', default=True) + created_at = models.DateTimeField(verbose_name='创建时间', auto_now_add=True) + updated_at = models.DateTimeField(verbose_name='更新时间', auto_now=True) + gmail_credential_id = models.CharField(verbose_name='Gmail凭证ID', max_length=255, blank=True, null=True) + needs_reauth = models.BooleanField(verbose_name='需要重新授权', default=False) + def __str__(self): - return f"{self.user.username}的Gmail认证" + return f"{self.name} ({self.gmail_email})" + + class Meta: + verbose_name = 'Gmail凭证' + verbose_name_plural = 'Gmail凭证' + unique_together = ('user', 'gmail_email') + ordering = ['-is_default', '-updated_at'] class GmailTalentMapping(models.Model): """Gmail达人映射关系模型""" @@ -802,3 +812,135 @@ class ConversationSummary(models.Model): def __str__(self): return f"{self.user.username}与{self.talent_email}的对话总结" + +class OperatorAccount(models.Model): + """运营账号信息表""" + + id = models.AutoField(primary_key=True) # 保留自动递增的ID字段 + uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID') + + POSITION_CHOICES = [ + ('editor', '编辑'), + ('planner', '策划'), + ('operator', '运营'), + ('admin', '管理员'), + ] + + username = models.CharField(max_length=100, unique=True, verbose_name='用户名') + password = models.CharField(max_length=255, verbose_name='密码') + real_name = models.CharField(max_length=50, verbose_name='真实姓名') + email = models.EmailField(verbose_name='邮箱') + phone = models.CharField(max_length=15, verbose_name='电话') + position = models.CharField(max_length=20, choices=POSITION_CHOICES, verbose_name='工作定位') + department = models.CharField(max_length=50, verbose_name='部门') + is_active = 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='更新时间') + + class Meta: + verbose_name = '运营账号' + verbose_name_plural = '运营账号' + + def __str__(self): + return f"{self.real_name} ({self.username})" + +class PlatformAccount(models.Model): + """平台账号信息表""" + + STATUS_CHOICES = [ + ('active', '正常'), + ('restricted', '限流'), + ('suspended', '封禁'), + ('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='关联运营') + 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='账号链接') + 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='更新时间') + last_login = models.DateTimeField(blank=True, null=True, verbose_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})" + +class Video(models.Model): + """视频信息表""" + + STATUS_CHOICES = [ + ('draft', '草稿'), + ('scheduled', '已排期'), + ('published', '已发布'), + ('failed', '发布失败'), + ('deleted', '已删除'), + ] + + platform_account = models.ForeignKey(PlatformAccount, on_delete=models.CASCADE, related_name='videos', verbose_name='发布账号') + title = models.CharField(max_length=200, verbose_name='视频标题') + description = models.TextField(blank=True, null=True, verbose_name='视频描述') + video_url = models.URLField(blank=True, null=True, verbose_name='视频地址') + local_path = models.CharField(max_length=255, blank=True, null=True, verbose_name='本地路径') + thumbnail_url = models.URLField(blank=True, null=True, verbose_name='缩略图地址') + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft', verbose_name='发布状态') + views_count = models.IntegerField(default=0, verbose_name='播放次数') + likes_count = models.IntegerField(default=0, verbose_name='点赞数') + comments_count = models.IntegerField(default=0, verbose_name='评论数') + 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='计划发布时间') + 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) + +class GmailNotificationQueue(models.Model): + """Gmail通知队列,存储因认证失败等原因未能处理的通知""" + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='gmail_notification_queue') + gmail_credential = models.ForeignKey(GmailCredential, on_delete=models.CASCADE, related_name='notification_queue') + email = models.EmailField(verbose_name='邮箱', max_length=255) + history_id = models.CharField(verbose_name='历史ID', max_length=100) + notification_data = models.TextField(verbose_name='通知数据', blank=True, null=True) + processed = models.BooleanField(verbose_name='是否已处理', default=False) + success = models.BooleanField(verbose_name='处理是否成功', default=False) + error_message = models.CharField(verbose_name='错误信息', max_length=255, blank=True, null=True) + created_at = models.DateTimeField(verbose_name='创建时间', auto_now_add=True) + processed_at = models.DateTimeField(verbose_name='处理时间', blank=True, null=True) + + def __str__(self): + return f"通知 {self.id} - {self.email} - {self.created_at}" + + class Meta: + verbose_name = 'Gmail通知队列' + verbose_name_plural = 'Gmail通知队列' + ordering = ['processed', 'created_at'] \ No newline at end of file diff --git a/user_management/routing.py b/user_management/routing.py index 7f4a052..2940404 100644 --- a/user_management/routing.py +++ b/user_management/routing.py @@ -3,4 +3,6 @@ from . import consumers websocket_urlpatterns = [ re_path(r'ws/notifications/$', consumers.NotificationConsumer.as_asgi()), + re_path(r'ws/chat/$', consumers.ChatConsumer.as_asgi()), + re_path(r'ws/chat/stream/$', consumers.ChatStreamConsumer.as_asgi()), ] \ No newline at end of file diff --git a/user_management/tasks.py b/user_management/tasks.py new file mode 100644 index 0000000..9f60ed9 --- /dev/null +++ b/user_management/tasks.py @@ -0,0 +1,143 @@ +import os +import logging +import requests +from celery import shared_task +from django.utils import timezone +from django.conf import settings + +logger = logging.getLogger(__name__) + +@shared_task +def publish_scheduled_video(video_id): + """定时发布视频的任务""" + from .models import Video + + try: + # 获取视频记录 + video = Video.objects.get(id=video_id) + + # 检查视频状态是否为已排期 + if video.status != 'scheduled': + logger.warning(f"视频 {video_id} 状态不是'已排期',当前状态: {video.status},跳过发布") + return + + # 检查视频文件是否存在 + if not video.local_path or not os.path.exists(video.local_path): + logger.error(f"视频 {video_id} 的本地文件不存在: {video.local_path}") + video.status = 'failed' + video.save() + return + + # 模拟上传到平台的过程 + # 在实际应用中,这里需要根据不同平台调用不同的API + platform_account = video.platform_account + platform_name = platform_account.platform_name + + # 模拟成功上传并获取视频URL + video_url = f"https://example.com/{platform_name}/{video.id}" + video_id = f"VID_{video.id}" + + # 在实际应用中,这里应该调用各平台的API + logger.info(f"模拟上传视频 {video.id} 到 {platform_name} 平台") + + # 更新视频状态 + video.status = 'published' + video.publish_time = timezone.now() + video.video_url = video_url + video.video_id = video_id + video.save() + + logger.info(f"视频 {video.id} 已成功发布到 {platform_name} 平台") + + # 记录到知识库 + _update_knowledge_base(video) + + return { + "success": True, + "video_id": video.id, + "platform": platform_name, + "publish_time": video.publish_time.strftime("%Y-%m-%d %H:%M:%S"), + "video_url": video_url + } + + except Video.DoesNotExist: + logger.error(f"未找到ID为 {video_id} 的视频记录") + return {"success": False, "error": f"未找到ID为 {video_id} 的视频记录"} + except Exception as e: + logger.error(f"发布视频 {video_id} 失败: {str(e)}") + + # 尝试更新视频状态为失败 + try: + video = Video.objects.get(id=video_id) + video.status = 'failed' + video.save() + except: + pass + + return {"success": False, "error": str(e)} + +def _update_knowledge_base(video): + """更新知识库中的视频信息""" + from .models import KnowledgeBase, KnowledgeBaseDocument + + try: + # 获取关联的平台账号和运营账号 + platform_account = video.platform_account + operator = platform_account.operator + + # 查找对应的知识库 + knowledge_base = KnowledgeBase.objects.filter( + name__contains=operator.real_name, + type='private' + ).first() + + if not knowledge_base: + logger.warning(f"未找到与运营账号 {operator.real_name} 关联的知识库") + return + + # 查找相关的文档 + document = KnowledgeBaseDocument.objects.filter( + knowledge_base=knowledge_base, + document_name__contains=video.title, + status='active' + ).first() + + if not document: + logger.warning(f"未找到与视频 {video.title} 关联的知识库文档") + return + + # 在实际应用中,这里应该调用外部API更新文档内容 + logger.info(f"更新知识库文档 {document.document_id} 的视频发布状态") + + # 模拟更新文档内容 + # 这里只记录日志,实际应用中需要调用外部API + + except Exception as e: + logger.error(f"更新知识库失败: {str(e)}") + +@shared_task +def check_scheduled_videos(): + """定期检查计划发布的视频,处理未被正确调度的视频""" + from .models import Video + from datetime import timedelta + + try: + # 查找所有已经过了计划发布时间但仍处于scheduled状态的视频 + now = timezone.now() + threshold = now - timedelta(minutes=30) # 30分钟容差 + + videos = Video.objects.filter( + status='scheduled', + scheduled_time__lt=threshold + ) + + for video in videos: + logger.warning(f"发现未按计划发布的视频: {video.id}, 计划发布时间: {video.scheduled_time}") + # 手动触发发布任务 + publish_scheduled_video.delay(video.id) + + return f"检查了 {videos.count()} 个未按计划发布的视频" + + except Exception as e: + logger.error(f"检查未发布视频失败: {str(e)}") + return f"检查未发布视频失败: {str(e)}" \ No newline at end of file diff --git a/user_management/urls.py b/user_management/urls.py index 8e1b645..832d5c9 100644 --- a/user_management/urls.py +++ b/user_management/urls.py @@ -27,7 +27,8 @@ from .views import ( sync_talent_emails, manage_user_goal, generate_conversation_summary, - get_recommended_reply + get_recommended_reply, + refresh_all_gmail_watches ) from .feishu_chat_views import ( process_feishu_table, @@ -35,6 +36,7 @@ from .feishu_chat_views import ( feishu_user_goal, check_goal_status ) +from . import gmail_account_views # 创建路由器 router = DefaultRouter() @@ -75,6 +77,20 @@ urlpatterns = [ path('gmail/check-auth/', check_gmail_auth, name='check_gmail_auth'), path('gmail/import-from-sender/', import_gmail_from_sender, name='import_gmail_from_sender'), path('gmail/sync-talent/', sync_talent_emails, name='sync_talent_emails'), + path('gmail/refresh-all-watches/', refresh_all_gmail_watches, name='refresh_all_gmail_watches'), + path('gmail/webhook/', gmail_webhook, name='gmail_webhook'), + # 添加新路由 + path('gmail/clear-cache/', gmail_account_views.clear_gmail_cache, name='clear_gmail_cache'), + + # Gmail账户管理 + path('gmail/accounts/', gmail_account_views.list_gmail_accounts, name='list_gmail_accounts'), + path('gmail/accounts/add/', gmail_account_views.add_gmail_account, name='add_gmail_account'), + path('gmail/accounts/auth-code/', gmail_account_views.handle_gmail_auth_code, name='handle_gmail_auth_code'), + path('gmail/accounts/update/', gmail_account_views.update_gmail_account, name='update_gmail_account'), + path('gmail/accounts/delete//', gmail_account_views.delete_gmail_account, name='delete_gmail_account'), + path('gmail/accounts/set-default/', gmail_account_views.set_default_gmail_account, name='set_default_gmail_account'), + path('gmail/accounts/refresh-watch/', gmail_account_views.refresh_gmail_account_watch, name='refresh_gmail_account_watch'), + path('gmail/accounts/check-auth/', gmail_account_views.check_specific_gmail_auth, name='check_specific_gmail_auth'), # 新增功能API path('user-goal/', manage_user_goal, name='manage_user_goal'), diff --git a/user_management/views.py b/user_management/views.py index 41a4b3e..2200182 100644 --- a/user_management/views.py +++ b/user_management/views.py @@ -713,12 +713,16 @@ class ChatHistoryViewSet(KnowledgeBasePermissionMixin, viewsets.ModelViewSet): 'dataset_external_id_list': [str(id) for id in external_id_list], 'dataset_names': [kb.name for kb in knowledge_bases] } - + + # 检查是否有自定义标题 + title = data.get('title', 'New chat') + # 创建用户问题记录 question_record = ChatHistory.objects.create( user=request.user, knowledge_base=knowledge_bases[0], # 使用第一个知识库作为主知识库 conversation_id=str(conversation_id), + title=title, # 设置标题 role='user', content=data['question'], metadata=metadata @@ -729,7 +733,7 @@ class ChatHistoryViewSet(KnowledgeBasePermissionMixin, viewsets.ModelViewSet): if use_stream: # 创建流式响应 - return StreamingHttpResponse( + response = StreamingHttpResponse( self._stream_answer_from_external_api( conversation_id=str(conversation_id), question_record=question_record, @@ -738,8 +742,15 @@ class ChatHistoryViewSet(KnowledgeBasePermissionMixin, viewsets.ModelViewSet): question=data['question'], metadata=metadata ), - content_type='text/event-stream' + content_type='text/event-stream', + status=status.HTTP_201_CREATED # 修改状态码为201 ) + + # 添加禁用缓存的头部 + response['Cache-Control'] = 'no-cache, no-store' + response['Connection'] = 'keep-alive' + + return response else: # 使用非流式输出 logger.info("使用非流式输出模式") @@ -754,29 +765,49 @@ class ChatHistoryViewSet(KnowledgeBasePermissionMixin, viewsets.ModelViewSet): }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) # 创建 AI 回答记录 - answer_record = ChatHistory.objects.create( - user=request.user, + answer_record = ChatHistory.objects.create( + user=request.user, knowledge_base=knowledge_bases[0], - conversation_id=str(conversation_id), - parent_id=str(question_record.id), - role='assistant', - content=answer, - metadata=metadata - ) + conversation_id=str(conversation_id), + title=title, # 设置标题 + parent_id=str(question_record.id), + role='assistant', + content=answer, + metadata=metadata + ) + + # 如果是新会话的第一条消息,并且没有自定义标题,则自动生成标题 + should_generate_title = not existing_records.exists() and (not title or title == 'New chat') + if should_generate_title: + try: + generated_title = self._generate_conversation_title_from_deepseek( + data['question'], + answer + ) + if generated_title: + # 更新所有相关记录的标题 + ChatHistory.objects.filter( + conversation_id=str(conversation_id) + ).update(title=generated_title) + title = generated_title + except Exception as e: + logger.error(f"自动生成标题失败: {str(e)}") + # 继续执行,不影响主流程 - return Response({ - 'code': 200, + return Response({ + 'code': 200, # 修改状态码为201 'message': '成功', - 'data': { - 'id': str(answer_record.id), - 'conversation_id': str(conversation_id), + 'data': { + 'id': str(answer_record.id), + 'conversation_id': str(conversation_id), + 'title': title, # 添加标题字段 'dataset_id_list': metadata.get('dataset_id_list', []), 'dataset_names': metadata.get('dataset_names', []), - 'role': 'assistant', + 'role': 'assistant', 'content': answer, - 'created_at': answer_record.created_at.strftime('%Y-%m-%d %H:%M:%S') - } - }) + 'created_at': answer_record.created_at.strftime('%Y-%m-%d %H:%M:%S') + } + }, status=status.HTTP_200_CREATED) # 修改状态码为201 except Exception as e: logger.error(f"创建聊天记录失败: {str(e)}") @@ -793,11 +824,15 @@ class ChatHistoryViewSet(KnowledgeBasePermissionMixin, viewsets.ModelViewSet): # 确保所有ID都是字符串 dataset_external_ids = [str(id) if isinstance(id, uuid.UUID) else id for id in dataset_external_id_list] + # 获取标题 + title = question_record.title or 'New chat' + # 创建AI回答记录对象,稍后更新内容 answer_record = ChatHistory.objects.create( user=question_record.user, knowledge_base=knowledge_bases[0], conversation_id=str(conversation_id), + title=title, # 设置标题 parent_id=str(question_record.id), role='assistant', content="", # 初始内容为空 @@ -907,11 +942,12 @@ class ChatHistoryViewSet(KnowledgeBasePermissionMixin, viewsets.ModelViewSet): # 构建响应数据 response_data = { - 'code': 200, + 'code': 200, # 修改状态码为201 'message': 'partial', 'data': { 'id': str(answer_record.id), 'conversation_id': str(conversation_id), + 'title': title, # 添加标题字段 'content': content_part, 'is_end': data.get('is_end', False) } @@ -927,13 +963,46 @@ class ChatHistoryViewSet(KnowledgeBasePermissionMixin, viewsets.ModelViewSet): answer_record.content = full_content.strip() answer_record.save() + # 先检查当前conversation_id是否已有有效标题 + current_title = ChatHistory.objects.filter( + conversation_id=str(conversation_id) + ).exclude( + title__in=["New chat", "新对话", ""] + ).values_list('title', flat=True).first() + + # 如果已有有效标题,则复用 + if current_title: + title = current_title + logger.info(f"复用已有标题: {title}") + else: + # 没有有效标题时,直接基于当前问题和回答生成标题 + try: + # 直接使用当前的问题和完整的AI回答来生成标题 + generated_title = self._generate_conversation_title_from_deepseek( + question, full_content.strip() + ) + if generated_title: + # 更新所有相关记录的标题 + ChatHistory.objects.filter( + conversation_id=str(conversation_id) + ).update(title=generated_title) + title = generated_title + logger.info(f"成功生成标题: {title}") + else: + title = "新对话" # 如果生成失败,使用默认标题 + logger.warning("生成标题失败,使用默认标题") + except Exception as e: + logger.error(f"自动生成标题失败: {str(e)}") + title = "新对话" # 如果出错,使用默认标题 + # 发送完整内容的最终响应 final_response = { - 'code': 200, + 'code': 200, # 修改状态码为201 'message': '完成', 'data': { 'id': str(answer_record.id), 'conversation_id': str(conversation_id), + 'title': title, # 添加生成的标题 'dataset_id_list': metadata.get('dataset_id_list', []), 'dataset_names': metadata.get('dataset_names', []), 'role': 'assistant', @@ -964,11 +1033,11 @@ class ChatHistoryViewSet(KnowledgeBasePermissionMixin, viewsets.ModelViewSet): full_content += content_part response_data = { - 'code': 200, + 'code': 200, # 修改状态码为201 'message': 'partial', 'data': { 'id': str(answer_record.id), - 'conversation_id': str(conversation_id), + 'conversation_id': str(conversation_id), # 添加标题字段 'content': content_part, 'is_end': data.get('is_end', False) } @@ -999,7 +1068,7 @@ class ChatHistoryViewSet(KnowledgeBasePermissionMixin, viewsets.ModelViewSet): answer_record.save() except Exception as save_error: logger.error(f"保存部分内容失败: {str(save_error)}") - + def _get_answer_from_external_api(self, dataset_external_id_list, question): """调用外部API获取AI回答(非流式版本)""" try: @@ -1893,6 +1962,119 @@ class ChatHistoryViewSet(KnowledgeBasePermissionMixin, viewsets.ModelViewSet): 'data': None }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + @action(detail=False, methods=['get'], url_path='generate-conversation-title') + def generate_conversation_title(self, request): + """更新会话标题""" + try: + conversation_id = request.query_params.get('conversation_id') + if not conversation_id: + return Response({ + 'code': 400, + 'message': '缺少conversation_id参数', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 检查对话是否存在 + messages = self.get_queryset().filter( + conversation_id=conversation_id, + is_deleted=False, + user=request.user + ).order_by('created_at') + + if not messages.exists(): + return Response({ + 'code': 404, + 'message': '对话不存在或无权访问', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) + + # 检查是否有自定义标题参数 + custom_title = request.query_params.get('title') + if not custom_title: + return Response({ + 'code': 400, + 'message': '缺少title参数', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 更新所有相关记录的标题 + ChatHistory.objects.filter( + conversation_id=conversation_id, + user=request.user + ).update(title=custom_title) + + return Response({ + 'code': 200, + 'message': '更新会话标题成功', + 'data': { + 'conversation_id': conversation_id, + 'title': custom_title + } + }) + + 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 _generate_conversation_title_from_deepseek(self, user_question, assistant_answer): + """调用SiliconCloud API生成会话标题,直接基于当前问题和回答内容""" + try: + # 从Django设置中获取API密钥 + api_key = settings.SILICON_CLOUD_API_KEY + if not api_key: + return "新对话" + + # 构建提示信息 + prompt = f"请根据用户的问题和助手的回答,生成一个简短的对话标题(不超过20个字)。\n\n用户问题: {user_question}\n\n助手回答: {assistant_answer}" + + import requests + + url = "https://api.siliconflow.cn/v1/chat/completions" + + payload = { + "model": "deepseek-ai/DeepSeek-V3", + "stream": False, + "max_tokens": 512, + "temperature": 0.7, + "top_p": 0.7, + "top_k": 50, + "frequency_penalty": 0.5, + "n": 1, + "stop": [], + "messages": [ + { + "role": "user", + "content": prompt + } + ] + } + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + response = requests.post(url, json=payload, headers=headers) + response_data = response.json() + + if response.status_code == 200 and 'choices' in response_data and response_data['choices']: + title = response_data['choices'][0]['message']['content'].strip() + return title[:50] # 截断过长的标题 + else: + logger.error(f"生成标题时出错: {response.text}") + return "新对话" + + except Exception as e: + logger.exception(f"生成对话标题时发生错误: {str(e)}") + return "新对话" + + + + class KnowledgeBaseViewSet(KnowledgeBasePermissionMixin, viewsets.ModelViewSet): serializer_class = KnowledgeBaseSerializer @@ -3166,7 +3348,8 @@ class KnowledgeBaseViewSet(KnowledgeBasePermissionMixin, viewsets.ModelViewSet): knowledge_base=instance, document_id=document_id, document_name=doc_name, - external_id=document_id + external_id=document_id, + uploader_name=user.name ) saved_documents.append({ @@ -3467,7 +3650,8 @@ class KnowledgeBaseViewSet(KnowledgeBasePermissionMixin, viewsets.ModelViewSet): # 添加外部API返回的额外信息 "char_length": next((d.get('char_length', 0) for d in external_documents if d.get('id') == doc.external_id), 0), "paragraph_count": next((d.get('paragraph_count', 0) for d in external_documents if d.get('id') == doc.external_id), 0), - "is_active": next((d.get('is_active', True) for d in external_documents if d.get('id') == doc.external_id), True) + "is_active": next((d.get('is_active', True) for d in external_documents if d.get('id') == doc.external_id), True), + "uploader_name": doc.uploader_name } for doc in documents] return Response({ @@ -4595,7 +4779,7 @@ class RegisterView(APIView): data = request.data # 检查必填字段 - required_fields = ['username', 'password', 'email', 'role', 'department', 'name'] + required_fields = ['username', 'password', 'email', 'role', 'name'] for field in required_fields: if not data.get(field): return Response({ @@ -4614,32 +4798,6 @@ class RegisterView(APIView): "data": None }, status=status.HTTP_400_BAD_REQUEST) - # 验证部门是否存在 - if data['department'] not in settings.DEPARTMENT_GROUPS: - return Response({ - "code": 400, - "message": f"无效的部门,可选部门: {', '.join(settings.DEPARTMENT_GROUPS.keys())}", - "data": None - }, status=status.HTTP_400_BAD_REQUEST) - - # 如果是组员,验证小组 - if data['role'] == 'member': - if not data.get('group'): - return Response({ - "code": 400, - "message": "组员必须指定所属小组", - "data": None - }, status=status.HTTP_400_BAD_REQUEST) - - # 验证小组是否存在且属于指定部门 - valid_groups = settings.DEPARTMENT_GROUPS.get(data['department'], []) - if data['group'] not in valid_groups: - return Response({ - "code": 400, - "message": f"无效的小组,{data['department']}的可选小组: {', '.join(valid_groups)}", - "data": None - }, status=status.HTTP_400_BAD_REQUEST) - # 检查用户名是否已存在 if User.objects.filter(username=data['username']).exists(): return Response({ @@ -4680,9 +4838,9 @@ class RegisterView(APIView): email=data['email'], password=data['password'], role=data['role'], - department=data['department'], + department=data.get('department'), # 不再强制要求部门 name=data['name'], - group=data.get('group') if data['role'] == 'member' else None, + group=data.get('group'), # 不再强制要求小组 is_staff=False, is_superuser=False ) @@ -4921,7 +5079,7 @@ def user_register(request): data = request.data # 检查必填字段 - required_fields = ['username', 'password', 'email', 'role', 'department', 'name'] + required_fields = ['username', 'password', 'email', 'role', 'name'] for field in required_fields: if not data.get(field): return Response({ @@ -4939,14 +5097,6 @@ def user_register(request): 'data': None }, status=status.HTTP_400_BAD_REQUEST) - # 如果是组员,必须指定小组 - if data['role'] == 'member' and not data.get('group'): - return Response({ - 'code': 400, - 'message': '组员必须指定所属小组', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - # 检查用户名是否已存在 if User.objects.filter(username=data['username']).exists(): return Response({ @@ -4987,9 +5137,9 @@ def user_register(request): email=data['email'], password=data['password'], role=data['role'], - department=data['department'], + department=data.get('department'), # 不再强制要求部门 name=data['name'], - group=data.get('group') if data['role'] == 'member' else None, + group=data.get('group'), # 不再强制要求小组 is_staff=False, is_superuser=False ) @@ -5374,7 +5524,11 @@ def setup_gmail_integration(request): for field in required_fields: if field not in request_data: logger.warning(f"缺少必填字段: {field}") - return Response({"error": f"缺少必填字段: {field}"}, status=status.HTTP_400_BAD_REQUEST) + return Response({ + 'code': 400, + 'message': f"缺少必填字段: {field}", + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) client_secret_json = request_data.get('client_secret_json') talent_gmail = request_data.get('talent_gmail') @@ -5405,10 +5559,11 @@ def setup_gmail_integration(request): if not auth_success: logger.error("授权码认证失败") - return Response( - {"error": "授权码认证失败,请确保授权码正确"}, - status=status.HTTP_400_BAD_REQUEST - ) + return Response({ + 'code': 400, + 'message': "授权码认证失败,请确保授权码正确", + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) logger.info("授权码认证成功") else: @@ -5425,21 +5580,22 @@ def setup_gmail_integration(request): auth_url = error_message.split("Please visit this URL to authorize: ")[1] logger.info("需要用户授权,返回授权URL") - return Response( - { + return Response({ + 'code': 202, + 'message': "请访问提供的URL获取授权码,然后与client_secret_json和talent_gmail一起提交", + 'data': { "status": "authorization_required", - "auth_url": auth_url, - "message": "请访问提供的URL获取授权码,然后与client_secret_json和talent_gmail一起提交" - }, - status=status.HTTP_202_ACCEPTED - ) + "auth_url": auth_url + } + }, status=status.HTTP_202_ACCEPTED) else: # 其他错误 logger.error(f"Gmail认证失败: {error_message}") - return Response( - {"error": f"Gmail认证失败: {error_message}"}, - status=status.HTTP_400_BAD_REQUEST - ) + return Response({ + 'code': 400, + 'message': f"Gmail认证失败: {error_message}", + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) # 创建或获取talent知识库 logger.info(f"创建或获取Gmail知识库: {talent_gmail}") @@ -5459,9 +5615,10 @@ def setup_gmail_integration(request): # 生成随机会话ID conversation_id = f"conv_{uuid.uuid4().hex[:10]}" - return Response( - { - "message": f"没有找到与 {talent_gmail} 的邮件对话,请确保您有与该地址的邮件往来", + return Response({ + 'code': 200, + 'message': f"没有找到与 {talent_gmail} 的邮件对话,请确保您有与该地址的邮件往来", + 'data': { "knowledge_base_id": str(knowledge_base.id), "conversation_id": conversation_id, "troubleshooting": { @@ -5469,9 +5626,8 @@ def setup_gmail_integration(request): "verify_address": "请确认邮箱地址拼写正确", "check_permissions": "请确保已授权完整的邮箱访问权限" } - }, - status=status.HTTP_200_OK - ) + } + }, status=status.HTTP_200_OK) # 保存对话到知识库 logger.info(f"将 {conversation_count} 个邮件对话保存到知识库") @@ -5479,24 +5635,25 @@ def setup_gmail_integration(request): conversation_id = result.get('conversation_id') logger.info(f"对话已保存到知识库,ID: {conversation_id}") - return Response( - { - "message": f"Gmail集成成功。已加载与 {talent_gmail} 的 {conversation_count} 个邮件对话到知识库。", + return Response({ + 'code': 200, + 'message': f"Gmail集成成功。已加载与 {talent_gmail} 的 {conversation_count} 个邮件对话到知识库。", + 'data': { "knowledge_base_id": str(knowledge_base.id), "conversation_id": conversation_id - }, - status=status.HTTP_200_OK - ) + } + }, status=status.HTTP_200_OK) except Exception as e: logger.error(f"设置Gmail集成失败: {str(e)}") import traceback logger.error(traceback.format_exc()) - return Response( - {"error": f"设置Gmail集成失败: {str(e)}"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) + return Response({ + 'code': 500, + 'message': f"设置Gmail集成失败: {str(e)}", + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @api_view(['POST']) @permission_classes([IsAuthenticated]) @@ -5614,8 +5771,9 @@ def gmail_webhook(request): if not data: return Response({ - 'status': 'error', - 'message': '无效的请求数据' + 'code': 400, + 'message': '无效的请求数据', + 'data': None }, status=status.HTTP_400_BAD_REQUEST) # 处理数据 @@ -5670,8 +5828,9 @@ def gmail_webhook(request): if not email_address or not history_id: return Response({ - 'status': 'error', - 'message': '缺少必要的参数' + 'code': 400, + 'message': '缺少必要的参数', + 'data': None }, status=status.HTTP_400_BAD_REQUEST) # 查找用户和认证信息 @@ -5686,16 +5845,18 @@ def gmail_webhook(request): else: logger.error(f"无法找到与{email_address}关联的用户") return Response({ - 'status': 'error', - 'message': f'找不到与 {email_address} 关联的用户' + 'code': 404, + 'message': f'找不到与 {email_address} 关联的用户', + 'data': None }, status=status.HTTP_404_NOT_FOUND) # 查找认证信息 credential = GmailCredential.objects.filter(user=user, is_active=True).first() if not credential: return Response({ - 'status': 'error', - 'message': f'找不到用户 {email_address} 的Gmail认证信息' + 'code': 404, + 'message': f'找不到用户 {email_address} 的Gmail认证信息', + 'data': None }, status=status.HTTP_404_NOT_FOUND) # 更新history_id @@ -5772,7 +5933,7 @@ def gmail_webhook(request): logger.error(traceback.format_exc()) return Response({ - 'status': 'success', + 'code': 200, 'message': '通知已处理', 'data': { 'user_id': str(user.id), @@ -5784,8 +5945,9 @@ def gmail_webhook(request): logger.error(f"处理Gmail webhook失败: {str(e)}") logger.error(traceback.format_exc()) return Response({ - 'status': 'error', - 'message': f'处理通知失败: {str(e)}' + 'code': 500, + 'message': f'处理通知失败: {str(e)}', + 'data': None }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @api_view(['GET']) @@ -5795,7 +5957,11 @@ def get_gmail_attachments(request): try: conversation_id = request.query_params.get('conversation_id') if not conversation_id: - return Response({"error": "缺少conversation_id参数"}, status=status.HTTP_400_BAD_REQUEST) + return Response({ + 'code': 400, + 'message': '缺少conversation_id参数', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) # 获取可选的代理设置 use_proxy = request.query_params.get('use_proxy', 'true').lower() == 'true' @@ -6319,7 +6485,7 @@ def manage_user_goal(request): - content: 用户总目标内容(可选,不提供则返回当前总目标) 返回: - - 包含操作结果和总目标信息的JSON响应 + - 包含操作结果和总目标信息的JSON响应,格式为 {data, code, message} """ user = request.user goal_content = request.data.get('content') @@ -6331,13 +6497,21 @@ def manage_user_goal(request): gmail_integration = GmailIntegration(user) result = gmail_integration.manage_user_goal(goal_content) - return Response(result, status=status.HTTP_200_OK) + # 转换为新的响应格式 + response_data = { + 'code': 200, + 'message': 'success', + 'data': result + } + + return Response(response_data, status=status.HTTP_200_OK) except Exception as e: - return Response( - {'status': 'error', 'message': str(e)}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) + return Response({ + 'code': 500, + 'message': str(e), + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @api_view(['POST']) @permission_classes([IsAuthenticated]) @@ -6349,16 +6523,17 @@ def generate_conversation_summary(request): - talent_email: 达人的邮箱地址 返回: - - 包含操作结果和总结信息的JSON响应 + - 包含操作结果和总结信息的JSON响应,格式为 {data, code, message} """ user = request.user talent_email = request.data.get('talent_email') if not talent_email: - return Response( - {'status': 'error', 'message': '缺少必要参数: talent_email'}, - status=status.HTTP_400_BAD_REQUEST - ) + return Response({ + 'code': 400, + 'message': '缺少必要参数: talent_email', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) try: from .gmail_integration import GmailIntegration @@ -6367,13 +6542,21 @@ def generate_conversation_summary(request): gmail_integration = GmailIntegration(user) result = gmail_integration.generate_conversation_summary(talent_email) - return Response(result, status=status.HTTP_200_OK) + # 转换为新的响应格式 + response_data = { + 'code': 200, + 'message': 'success', + 'data': result + } + + return Response(response_data, status=status.HTTP_200_OK) except Exception as e: - return Response( - {'status': 'error', 'message': str(e)}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) + return Response({ + 'code': 500, + 'message': str(e), + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @api_view(['POST']) @permission_classes([IsAuthenticated]) @@ -6386,17 +6569,18 @@ def get_recommended_reply(request): - talent_email: 达人的邮箱地址 返回: - - 包含推荐回复的JSON响应 + - 包含推荐回复的JSON响应,格式为 {data, code, message} """ user = request.user conversation_id = request.data.get('conversation_id') talent_email = request.data.get('talent_email') if not conversation_id or not talent_email: - return Response( - {'status': 'error', 'message': '缺少必要参数: conversation_id 或 talent_email'}, - status=status.HTTP_400_BAD_REQUEST - ) + return Response({ + 'code': 400, + 'message': '缺少必要参数: conversation_id 或 talent_email', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) try: from .gmail_integration import GmailIntegration @@ -6410,10 +6594,11 @@ def get_recommended_reply(request): ).order_by('created_at') if not chat_history: - return Response( - {'status': 'error', 'message': '找不到对话历史'}, - status=status.HTTP_404_NOT_FOUND - ) + return Response({ + 'code': 404, + 'message': '找不到对话历史', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) # 转换为DeepSeek API所需的格式 conversation_history = [] @@ -6438,21 +6623,26 @@ def get_recommended_reply(request): reply = gmail_integration._get_recommended_reply_from_deepseek(conversation_history) if not reply: - return Response( - {'status': 'error', 'message': '生成推荐回复失败'}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) + return Response({ + 'code': 500, + 'message': '生成推荐回复失败', + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response({ - 'status': 'success', - 'reply': reply + 'code': 200, + 'message': 'success', + 'data': { + 'reply': reply + } }, status=status.HTTP_200_OK) except Exception as e: - return Response( - {'status': 'error', 'message': str(e)}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) + return Response({ + 'code': 500, + 'message': str(e), + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @csrf_exempt @api_view(['POST']) @@ -6466,7 +6656,8 @@ def feishu_sync_api(request): if not (request.user.has_perm('feishu.can_sync_feishu') or request.user.role in ['admin', 'leader']): return Response({ 'code': 403, - 'message': '您没有同步飞书数据的权限' + 'message': '您没有同步飞书数据的权限', + 'data': None }, status=403) # 获取参数 @@ -6481,7 +6672,8 @@ def feishu_sync_api(request): if not sync_all and not creator_ids: return Response({ 'code': 400, - 'message': '请指定sync_all=true或提供creator_ids列表' + 'message': '请指定sync_all=true或提供creator_ids列表', + 'data': None }, status=400) try: @@ -6598,7 +6790,8 @@ def feishu_to_kb_api(request): if not (request.user.has_perm('feishu.can_sync_feishu') or request.user.role in ['admin', 'leader']): return Response({ 'code': 403, - 'message': '您没有同步飞书数据的权限' + 'message': '您没有同步飞书数据的权限', + 'data': None }, status=403) # 获取参数 @@ -6741,7 +6934,8 @@ def check_creator_kb_api(request): if not request.user.has_perm('feishu.can_view_feishu'): return Response({ 'code': 403, - 'message': '您没有查看飞书数据的权限' + 'message': '您没有查看飞书数据的权限', + 'data': None }, status=403) try: @@ -6941,10 +7135,11 @@ def process_feishu_table(request): try: # 检查用户权限 - 只允许组长使用 if request.user.role != 'leader': - return Response( - {"error": "只有组长角色的用户可以使用此功能"}, - status=status.HTTP_403_FORBIDDEN - ) + return Response({ + 'code': 403, + 'message': '只有组长角色的用户可以使用此功能', + 'data': None + }, status=status.HTTP_403_FORBIDDEN) # 获取参数 table_id = request.data.get("table_id") @@ -6960,15 +7155,17 @@ def process_feishu_table(request): # 验证必要参数 if not table_id: - return Response( - {"error": "缺少参数table_id"}, - status=status.HTTP_400_BAD_REQUEST - ) + return Response({ + 'code': 400, + 'message': '缺少参数table_id', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) if not view_id: - return Response( - {"error": "缺少参数view_id"}, - status=status.HTTP_400_BAD_REQUEST - ) + return Response({ + 'code': 400, + 'message': '缺少参数view_id', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) # 从飞书表格获取记录 records = fetch_table_records( @@ -6979,19 +7176,21 @@ def process_feishu_table(request): ) if not records: - return Response( - {"message": "未获取到任何记录"}, - status=status.HTTP_404_NOT_FOUND - ) + return Response({ + 'code': 404, + 'message': '未获取到任何记录', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) # 查找重复邮箱的创作者 duplicate_emails = find_duplicate_email_creators(records) if not duplicate_emails: - return Response( - {"message": "未发现重复邮箱"}, - status=status.HTTP_200_OK - ) + return Response({ + 'code': 200, + 'message': '未发现重复邮箱', + 'data': None + }, status=status.HTTP_200_OK) # 处理重复邮箱记录 results = process_duplicate_emails(duplicate_emails, goal_template) @@ -7011,20 +7210,24 @@ def process_feishu_table(request): # 返回处理结果 return Response({ - 'status': 'success', - 'records_count': len(records), - 'duplicate_emails_count': len(duplicate_emails), - 'processing_results': results, - 'chat_results': chat_results + 'code': 200, + 'message': 'success', + 'data': { + 'records_count': len(records), + 'duplicate_emails_count': len(duplicate_emails), + 'processing_results': results, + 'chat_results': chat_results + } }, status=status.HTTP_200_OK) except Exception as e: logger.error(f"处理飞书表格时出错: {str(e)}") logger.error(traceback.format_exc()) - return Response( - {"error": str(e)}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) + return Response({ + 'code': 500, + 'message': str(e), + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @api_view(['POST']) @permission_classes([IsAuthenticated]) @@ -7039,10 +7242,11 @@ def run_auto_chat(request): try: # 检查用户权限 - 只允许组长使用 if request.user.role != 'leader': - return Response( - {"error": "只有组长角色的用户可以使用此功能"}, - status=status.HTTP_403_FORBIDDEN - ) + return Response({ + 'code': 403, + 'message': '只有组长角色的用户可以使用此功能', + 'data': None + }, status=status.HTTP_403_FORBIDDEN) # 获取参数 email = request.data.get("email") @@ -7050,24 +7254,30 @@ def run_auto_chat(request): # 验证必要参数 if not email: - return Response( - {"error": "缺少参数email"}, - status=status.HTTP_400_BAD_REQUEST - ) + return Response({ + 'code': 400, + 'message': '缺少参数email', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) # 执行自动对话 result = auto_chat_session(request.user, email, max_turns=turns) # 返回结果 - return Response(result, status=status.HTTP_200_OK) + return Response({ + 'code': 200, + 'message': 'success', + 'data': result + }, status=status.HTTP_200_OK) except Exception as e: logger.error(f"执行自动对话时出错: {str(e)}") logger.error(traceback.format_exc()) - return Response( - {"error": str(e)}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) + return Response({ + 'code': 500, + 'message': str(e), + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @api_view(['GET', 'POST']) @permission_classes([IsAuthenticated]) @@ -7085,17 +7295,22 @@ def feishu_user_goal(request): try: # 检查用户权限 - 只允许组长使用 if request.user.role != 'leader': - return Response( - {"error": "只有组长角色的用户可以使用此功能"}, - status=status.HTTP_403_FORBIDDEN - ) + return Response({ + 'code': 403, + 'message': '只有组长角色的用户可以使用此功能', + 'data': None + }, status=status.HTTP_403_FORBIDDEN) if request.method == 'GET': # 创建Gmail集成实例 gmail_integration = GmailIntegration(request.user) # 获取总目标 result = gmail_integration.manage_user_goal() - return Response(result, status=status.HTTP_200_OK) + return Response({ + 'code': 200, + 'message': 'success', + 'data': result + }, status=status.HTTP_200_OK) elif request.method == 'POST': # 获取参数 @@ -7104,29 +7319,36 @@ def feishu_user_goal(request): # 验证必要参数 if not email: - return Response( - {"error": "缺少参数email"}, - status=status.HTTP_400_BAD_REQUEST - ) + return Response({ + 'code': 400, + 'message': '缺少参数email', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) if not goal: - return Response( - {"error": "缺少参数goal"}, - status=status.HTTP_400_BAD_REQUEST - ) + return Response({ + 'code': 400, + 'message': '缺少参数goal', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) # 设置用户总目标 gmail_integration = GmailIntegration(request.user) result = gmail_integration.manage_user_goal(goal) - return Response(result, status=status.HTTP_200_OK) + return Response({ + 'code': 200, + 'message': 'success', + 'data': result + }, status=status.HTTP_200_OK) except Exception as e: logger.error(f"管理用户总目标时出错: {str(e)}") logger.error(traceback.format_exc()) - return Response( - {"error": str(e)}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) + return Response({ + 'code': 500, + 'message': str(e), + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @api_view(['GET']) @permission_classes([IsAuthenticated]) @@ -7140,20 +7362,22 @@ def check_goal_status(request): try: # 检查用户权限 - 只允许组长使用 if request.user.role != 'leader': - return Response( - {"error": "只有组长角色的用户可以使用此功能"}, - status=status.HTTP_403_FORBIDDEN - ) + return Response({ + 'code': 403, + 'message': '只有组长角色的用户可以使用此功能', + 'data': None + }, status=status.HTTP_403_FORBIDDEN) # 获取参数 email = request.query_params.get("email") # 验证必要参数 if not email: - return Response( - {"error": "缺少参数email"}, - status=status.HTTP_400_BAD_REQUEST - ) + return Response({ + 'code': 400, + 'message': '缺少参数email', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) # 查找Gmail映射关系 mapping = GmailTalentMapping.objects.filter( @@ -7163,10 +7387,11 @@ def check_goal_status(request): ).first() if not mapping: - return Response( - {"error": f"找不到与邮箱 {email} 的映射关系"}, - status=status.HTTP_404_NOT_FOUND - ) + return Response({ + 'code': 404, + 'message': f'找不到与邮箱 {email} 的映射关系', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) # 获取对话历史中最后的AI回复 last_ai_message = ChatHistory.objects.filter( @@ -7178,10 +7403,11 @@ def check_goal_status(request): ).order_by('-created_at').first() if not last_ai_message: - return Response( - {"error": f"找不到与邮箱 {email} 的对话历史"}, - status=status.HTTP_404_NOT_FOUND - ) + return Response({ + 'code': 404, + 'message': f'找不到与邮箱 {email} 的对话历史', + 'data': None + }, status=status.HTTP_404_NOT_FOUND) # 导入检查函数 from feishu.feishu_ai_chat import check_goal_achieved @@ -7205,12 +7431,17 @@ def check_goal_status(request): 'summary': summary.summary if summary else None } - return Response(result, status=status.HTTP_200_OK) + return Response({ + 'code': 200, + 'message': 'success', + 'data': result + }, status=status.HTTP_200_OK) except Exception as e: logger.error(f"检查目标状态时出错: {str(e)}") logger.error(traceback.format_exc()) - return Response( - {"error": str(e)}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) \ No newline at end of file + return Response({ + 'code': 500, + 'message': str(e), + 'data': None + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) \ No newline at end of file diff --git a/测试视频.mp4 b/测试视频.mp4 new file mode 100644 index 0000000..4d9a5ee Binary files /dev/null and b/测试视频.mp4 differ