群发demo1

This commit is contained in:
wanjia 2025-05-29 17:21:16 +08:00
parent 5eab14304c
commit 7898219d2c
33 changed files with 2425 additions and 1029 deletions

View File

@ -27,7 +27,6 @@ class Brand(models.Model):
is_active = models.BooleanField(default=True, verbose_name='是否激活')
class Meta:
db_table = 'brands'
verbose_name = '品牌'
verbose_name_plural = '品牌'
@ -66,7 +65,6 @@ class Product(models.Model):
is_active = models.BooleanField(default=True, verbose_name='是否激活')
class Meta:
db_table = 'products'
verbose_name = '产品'
verbose_name_plural = '产品'
unique_together = ['brand', 'name']
@ -144,7 +142,6 @@ class Campaign(models.Model):
is_active = models.BooleanField(default=True, verbose_name='是否激活')
class Meta:
db_table = 'campaigns'
verbose_name = '活动'
verbose_name_plural = '活动'
unique_together = ['brand', 'name']
@ -186,7 +183,6 @@ class BrandChatSession(models.Model):
is_active = models.BooleanField(default=True, verbose_name='是否激活')
class Meta:
db_table = 'brand_chat_sessions'
verbose_name = '品牌聊天会话'
verbose_name_plural = '品牌聊天会话'
indexes = [

View File

@ -1,6 +1,7 @@
# Generated by Django 5.2.1 on 2025-05-28 09:51
# Generated by Django 5.2 on 2025-05-07 03:40
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
@ -15,6 +16,24 @@ class Migration(migrations.Migration):
]
operations = [
migrations.CreateModel(
name='ConversationSummary',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('talent_email', models.EmailField(max_length=254, verbose_name='达人邮箱')),
('conversation_id', models.CharField(max_length=100, verbose_name='对话ID')),
('summary', models.TextField(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='更新时间')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='conversation_summaries', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': '对话总结',
'verbose_name_plural': '对话总结',
'db_table': 'conversation_summaries',
},
),
migrations.CreateModel(
name='ChatHistory',
fields=[
@ -32,8 +51,9 @@ class Migration(migrations.Migration):
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'chat_history',
'ordering': ['created_at'],
'indexes': [models.Index(fields=['conversation_id', 'created_at'], name='chat_chathi_convers_90ca0c_idx'), models.Index(fields=['user', 'created_at'], name='chat_chathi_user_id_326d36_idx'), models.Index(fields=['conversation_id', 'is_deleted'], name='chat_chathi_convers_bcd094_idx')],
'indexes': [models.Index(fields=['conversation_id', 'created_at'], name='chat_histor_convers_33721a_idx'), models.Index(fields=['user', 'created_at'], name='chat_histor_user_id_aa050a_idx'), models.Index(fields=['conversation_id', 'is_deleted'], name='chat_histor_convers_89bc43_idx')],
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2 on 2025-05-15 10:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('chat', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='chathistory',
name='content',
field=models.TextField(help_text='消息内容,支持存储长文本', max_length=65535),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2 on 2025-05-20 09:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('chat', '0002_alter_chathistory_content'),
]
operations = [
migrations.AlterField(
model_name='chathistory',
name='content',
field=models.TextField(),
),
]

View File

@ -0,0 +1,39 @@
# Generated by Django 5.2 on 2025-05-21 04:30
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('chat', '0003_alter_chathistory_content'),
]
operations = [
migrations.RemoveField(
model_name='conversationsummary',
name='user',
),
migrations.RenameIndex(
model_name='chathistory',
new_name='chat_chathi_convers_90ca0c_idx',
old_name='chat_histor_convers_33721a_idx',
),
migrations.RenameIndex(
model_name='chathistory',
new_name='chat_chathi_user_id_326d36_idx',
old_name='chat_histor_user_id_aa050a_idx',
),
migrations.RenameIndex(
model_name='chathistory',
new_name='chat_chathi_convers_bcd094_idx',
old_name='chat_histor_convers_89bc43_idx',
),
migrations.AlterModelTable(
name='chathistory',
table=None,
),
migrations.DeleteModel(
name='ConversationSummary',
),
]

View File

@ -14,15 +14,16 @@ from apps.knowledge_base.models import KnowledgeBase
from apps.chat.models import ChatHistory
from apps.chat.serializers import ChatHistorySerializer
from apps.common.services.chat_service import ChatService
from apps.user.authentication import CustomTokenAuthentication
from apps.chat.services.chat_api import (
ExternalAPIError, stream_chat_answer, get_chat_answer, generate_conversation_title,
get_hit_test_documents, generate_conversation_title_from_deepseek
)
from apps.user.authentication import CustomTokenAuthentication
# from apps.permissions.services.permission_service import KnowledgeBasePermissionMixin
logger = logging.getLogger(__name__)
# class ChatHistoryViewSet(KnowledgeBasePermissionMixin, viewsets.ModelViewSet):
class ChatHistoryViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
authentication_classes = [CustomTokenAuthentication]
@ -463,83 +464,83 @@ class ChatHistoryViewSet(viewsets.ModelViewSet):
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=False, methods=['post'])
def hit_test(self, request):
"""获取问题与知识库文档的匹配度"""
try:
data = request.data
if 'question' not in data or 'dataset_id_list' not in data or not data['dataset_id_list']:
return Response({
'code': 400,
'message': '缺少必填字段: question 或 dataset_id_list',
'data': None
}, status=status.HTTP_400_BAD_REQUEST)
# @action(detail=False, methods=['post'])
# def hit_test(self, request):
# """获取问题与知识库文档的匹配度"""
# try:
# data = request.data
# if 'question' not in data or 'dataset_id_list' not in data or not data['dataset_id_list']:
# return Response({
# 'code': 400,
# 'message': '缺少必填字段: question 或 dataset_id_list',
# 'data': None
# }, status=status.HTTP_400_BAD_REQUEST)
question = data['question']
dataset_ids = data['dataset_id_list']
if not isinstance(dataset_ids, list):
try:
dataset_ids = json.loads(dataset_ids)
if not isinstance(dataset_ids, list):
dataset_ids = [dataset_ids]
except (json.JSONDecodeError, TypeError):
dataset_ids = [dataset_ids]
# question = data['question']
# dataset_ids = data['dataset_id_list']
# if not isinstance(dataset_ids, list):
# try:
# dataset_ids = json.loads(dataset_ids)
# if not isinstance(dataset_ids, list):
# dataset_ids = [dataset_ids]
# except (json.JSONDecodeError, TypeError):
# dataset_ids = [dataset_ids]
external_id_list = []
for kb_id in dataset_ids:
kb = KnowledgeBase.objects.filter(id=kb_id).first()
if not kb:
return Response({
'code': 404,
'message': f'知识库不存在: {kb_id}',
'data': None
}, status=status.HTTP_404_NOT_FOUND)
if not self.check_knowledge_base_permission(kb, request.user, 'read'):
return Response({
'code': 403,
'message': f'无权访问知识库: {kb.name}',
'data': None
}, status=status.HTTP_403_FORBIDDEN)
if kb.external_id:
external_id_list.append(str(kb.external_id))
# external_id_list = []
# for kb_id in dataset_ids:
# kb = KnowledgeBase.objects.filter(id=kb_id).first()
# if not kb:
# return Response({
# 'code': 404,
# 'message': f'知识库不存在: {kb_id}',
# 'data': None
# }, status=status.HTTP_404_NOT_FOUND)
# if not self.check_knowledge_base_permission(kb, request.user, 'read'):
# return Response({
# 'code': 403,
# 'message': f'无权访问知识库: {kb.name}',
# 'data': None
# }, status=status.HTTP_403_FORBIDDEN)
# if kb.external_id:
# external_id_list.append(str(kb.external_id))
if not external_id_list:
return Response({
'code': 400,
'message': '没有有效的知识库external_id',
'data': None
}, status=status.HTTP_400_BAD_REQUEST)
# if not external_id_list:
# return Response({
# 'code': 400,
# 'message': '没有有效的知识库external_id',
# 'data': None
# }, status=status.HTTP_400_BAD_REQUEST)
all_documents = []
for dataset_id in external_id_list:
try:
doc_info = get_hit_test_documents(dataset_id, question)
if doc_info:
all_documents.extend(doc_info)
except ExternalAPIError as e:
logger.error(f"调用hit_test失败: 知识库ID={dataset_id}, 错误={str(e)}")
continue # 宽松处理,跳过失败的知识库
# all_documents = []
# for dataset_id in external_id_list:
# try:
# doc_info = get_hit_test_documents(dataset_id, question)
# if doc_info:
# all_documents.extend(doc_info)
# except ExternalAPIError as e:
# logger.error(f"调用hit_test失败: 知识库ID={dataset_id}, 错误={str(e)}")
# continue # 宽松处理,跳过失败的知识库
all_documents = sorted(all_documents, key=lambda x: x.get('similarity', 0), reverse=True)
return Response({
'code': 200,
'message': '成功',
'data': {
'question': question,
'matched_documents': all_documents,
'total_count': len(all_documents)
}
})
# all_documents = sorted(all_documents, key=lambda x: x.get('similarity', 0), reverse=True)
# return Response({
# 'code': 200,
# 'message': '成功',
# 'data': {
# 'question': question,
# 'matched_documents': all_documents,
# 'total_count': len(all_documents)
# }
# })
except Exception as e:
logger.error(f"hit_test接口调用失败: {str(e)}")
import traceback
logger.error(traceback.format_exc())
return Response({
'code': 500,
'message': f'hit_test接口调用失败: {str(e)}',
'data': None
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
# except Exception as e:
# logger.error(f"hit_test接口调用失败: {str(e)}")
# import traceback
# logger.error(traceback.format_exc())
# return Response({
# 'code': 500,
# 'message': f'hit_test接口调用失败: {str(e)}',
# 'data': None
# }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def _highlight_keyword(self, text, keyword):
"""高亮关键词"""

View File

@ -6,11 +6,12 @@ from django.db import transaction
from apps.user.models import User
from apps.knowledge_base.models import KnowledgeBase
from apps.chat.models import ChatHistory
# from apps.permissions.services.permission_service import KnowledgeBasePermissionMixin
from django.db.models import Q
logger = logging.getLogger(__name__)
# class ChatService(KnowledgeBasePermissionMixin):
class ChatService():
@transaction.atomic
def create_chat_record(self, user, data, conversation_id=None):

View File

@ -0,0 +1,51 @@
# # apps/common/services/notification_service.py
# import logging
# from asgiref.sync import async_to_sync
# from channels.layers import get_channel_layer
# from apps.notification.models import Notification
# logger = logging.getLogger(__name__)
# class NotificationService:
# def send_notification(self, user, title, content, notification_type, related_object_id, sender=None):
# """发送通知并通过WebSocket推送"""
# try:
# notification = Notification.objects.create(
# sender=sender,
# receiver=user,
# title=title,
# content=content,
# type=notification_type,
# related_resource=related_object_id,
# )
# # 准备发送到WebSocket的数据
# notification_data = {
# "id": str(notification.id),
# "title": notification.title,
# "content": notification.content,
# "type": notification.type,
# "created_at": notification.created_at.isoformat(),
# }
# # 只有当sender不为None时才添加sender信息
# if notification.sender:
# notification_data["sender"] = {
# "id": str(notification.sender.id),
# "name": notification.sender.name
# }
# channel_layer = get_channel_layer()
# async_to_sync(channel_layer.group_send)(
# f"notification_user_{user.id}",
# {
# "type": "notification",
# "data": notification_data
# }
# )
# return notification
# except Exception as e:
# logger.error(f"发送通知失败: {str(e)}")
# return None

View File

@ -1,338 +1,338 @@
# apps/common/services/permission_service.py
import logging
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from rest_framework.exceptions import ValidationError
from apps.user.models import User
from apps.knowledge_base.models import KnowledgeBase
# # apps/common/services/permission_service.py
# import logging
# from django.db import transaction
# from django.db.models import Q
# from django.utils import timezone
# from rest_framework.exceptions import ValidationError
# from apps.user.models import User
# from apps.knowledge_base.models import KnowledgeBase
# from apps.permissions.models import Permission, KnowledgeBasePermission as KBPermissionModel
# logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# class PermissionService:
# def can_manage_knowledge_base(self, user, knowledge_base):
# """检查用户是否是知识库的创建者"""
# return str(knowledge_base.user_id) == str(user.id)
class PermissionService:
def can_manage_knowledge_base(self, user, knowledge_base):
"""检查用户是否是知识库的创建者"""
return str(knowledge_base.user_id) == str(user.id)
# def check_extend_permission(self, permission, user):
# """检查是否有权限延长权限有效期"""
# knowledge_base = permission.knowledge_base
# if knowledge_base.type == 'private':
# return knowledge_base.user_id == user.id
# if knowledge_base.type == 'leader':
# return user.role == 'admin'
# if knowledge_base.type == 'member':
# return user.role == 'admin' or (
# user.role == 'leader' and user.department == knowledge_base.department
# )
# return False
def check_extend_permission(self, permission, user):
"""检查是否有权限延长权限有效期"""
knowledge_base = permission.knowledge_base
if knowledge_base.type == 'private':
return knowledge_base.user_id == user.id
if knowledge_base.type == 'leader':
return user.role == 'admin'
if knowledge_base.type == 'member':
return user.role == 'admin' or (
user.role == 'leader' and user.department == knowledge_base.department
)
return False
# def create_permission_request(self, user, validated_data, notification_service):
# """创建权限申请并发送通知"""
# knowledge_base = validated_data['knowledge_base']
# if str(knowledge_base.user_id) == str(user.id):
# raise ValidationError({
# "code": 400,
# "message": "您是此知识库的创建者,无需申请权限",
# "data": None
# })
def create_permission_request(self, user, validated_data, notification_service):
"""创建权限申请并发送通知"""
knowledge_base = validated_data['knowledge_base']
if str(knowledge_base.user_id) == str(user.id):
raise ValidationError({
"code": 400,
"message": "您是此知识库的创建者,无需申请权限",
"data": None
})
# approver = User.objects.get(id=knowledge_base.user_id)
# requested_permissions = validated_data.get('permissions', {})
# expires_at = validated_data.get('expires_at')
approver = User.objects.get(id=knowledge_base.user_id)
requested_permissions = validated_data.get('permissions', {})
expires_at = validated_data.get('expires_at')
# if not any([requested_permissions.get('can_read'),
# requested_permissions.get('can_edit'),
# requested_permissions.get('can_delete')]):
# raise ValidationError("至少需要申请一种权限(读/改/删)")
if not any([requested_permissions.get('can_read'),
requested_permissions.get('can_edit'),
requested_permissions.get('can_delete')]):
raise ValidationError("至少需要申请一种权限(读/改/删)")
# if not expires_at:
# raise ValidationError("请指定权限到期时间")
if not expires_at:
raise ValidationError("请指定权限到期时间")
# existing_request = Permission.objects.filter(
# knowledge_base=knowledge_base,
# applicant=user,
# status='pending'
# ).first()
# if existing_request:
# raise ValidationError("您已有一个待处理的权限申请")
existing_request = Permission.objects.filter(
knowledge_base=knowledge_base,
applicant=user,
status='pending'
).first()
if existing_request:
raise ValidationError("您已有一个待处理的权限申请")
# existing_permission = Permission.objects.filter(
# knowledge_base=knowledge_base,
# applicant=user,
# status='approved',
# expires_at__gt=timezone.now()
# ).first()
# if existing_permission:
# raise ValidationError("您已有此知识库的访问权限")
existing_permission = Permission.objects.filter(
knowledge_base=knowledge_base,
applicant=user,
status='approved',
expires_at__gt=timezone.now()
).first()
if existing_permission:
raise ValidationError("您已有此知识库的访问权限")
# with transaction.atomic():
# permission = Permission.objects.create(
# knowledge_base=knowledge_base,
# applicant=user,
# approver=approver,
# permissions=requested_permissions,
# expires_at=expires_at,
# status='pending'
# )
with transaction.atomic():
permission = Permission.objects.create(
knowledge_base=knowledge_base,
applicant=user,
approver=approver,
permissions=requested_permissions,
expires_at=expires_at,
status='pending'
)
# permission_types = []
# if requested_permissions.get('can_read'):
# permission_types.append('读取')
# if requested_permissions.get('can_edit'):
# permission_types.append('编辑')
# if requested_permissions.get('can_delete'):
# permission_types.append('删除')
# permission_str = '、'.join(permission_types)
permission_types = []
if requested_permissions.get('can_read'):
permission_types.append('读取')
if requested_permissions.get('can_edit'):
permission_types.append('编辑')
if requested_permissions.get('can_delete'):
permission_types.append('删除')
permission_str = ''.join(permission_types)
# notification_service.send_notification(
# user=approver,
# title="新的权限申请",
# content=f"用户 {user.name} 申请了知识库 '{knowledge_base.name}' 的{permission_str}权限",
# notification_type="permission_request",
# related_object_id=str(permission.id)
# )
notification_service.send_notification(
user=approver,
title="新的权限申请",
content=f"用户 {user.name} 申请了知识库 '{knowledge_base.name}'{permission_str}权限",
notification_type="permission_request",
related_object_id=str(permission.id)
)
# return permission
return permission
# def approve_permission(self, user, permission, response_message, notification_service):
# """审批权限申请"""
# if not self.can_manage_knowledge_base(user, permission.knowledge_base):
# raise ValidationError({
# 'code': 403,
# 'message': '只有知识库创建者可以审批此申请',
# 'data': None
# })
def approve_permission(self, user, permission, response_message, notification_service):
"""审批权限申请"""
if not self.can_manage_knowledge_base(user, permission.knowledge_base):
raise ValidationError({
'code': 403,
'message': '只有知识库创建者可以审批此申请',
'data': None
})
# with transaction.atomic():
# permission.status = 'approved'
# permission.approver = user
# permission.response_message = response_message
# permission.save()
with transaction.atomic():
permission.status = 'approved'
permission.approver = user
permission.response_message = response_message
permission.save()
# kb_permission = KBPermissionModel.objects.filter(
# knowledge_base=permission.knowledge_base,
# user=permission.applicant
# ).first()
kb_permission = KBPermissionModel.objects.filter(
knowledge_base=permission.knowledge_base,
user=permission.applicant
).first()
# if kb_permission:
# kb_permission.can_read = permission.permissions.get('can_read', False)
# kb_permission.can_edit = permission.permissions.get('can_edit', False)
# kb_permission.can_delete = permission.permissions.get('can_delete', False)
# kb_permission.granted_by = user
# kb_permission.status = 'active'
# kb_permission.expires_at = permission.expires_at
# kb_permission.save()
# logger.info(f"更新知识库权限记录: {kb_permission.id}")
# else:
# kb_permission = KBPermissionModel.objects.create(
# knowledge_base=permission.knowledge_base,
# user=permission.applicant,
# can_read=permission.permissions.get('can_read', False),
# can_edit=permission.permissions.get('can_edit', False),
# can_delete=permission.permissions.get('can_delete', False),
# granted_by=user,
# status='active',
# expires_at=permission.expires_at
# )
# logger.info(f"创建新的知识库权限记录: {kb_permission.id}")
if kb_permission:
kb_permission.can_read = permission.permissions.get('can_read', False)
kb_permission.can_edit = permission.permissions.get('can_edit', False)
kb_permission.can_delete = permission.permissions.get('can_delete', False)
kb_permission.granted_by = user
kb_permission.status = 'active'
kb_permission.expires_at = permission.expires_at
kb_permission.save()
logger.info(f"更新知识库权限记录: {kb_permission.id}")
else:
kb_permission = KBPermissionModel.objects.create(
knowledge_base=permission.knowledge_base,
user=permission.applicant,
can_read=permission.permissions.get('can_read', False),
can_edit=permission.permissions.get('can_edit', False),
can_delete=permission.permissions.get('can_delete', False),
granted_by=user,
status='active',
expires_at=permission.expires_at
)
logger.info(f"创建新的知识库权限记录: {kb_permission.id}")
# notification_service.send_notification(
# user=permission.applicant,
# title="权限申请已通过",
# content=f"您对知识库 '{permission.knowledge_base.name}' 的权限申请已通过",
# notification_type="permission_approved",
# related_object_id=str(permission.id)
# )
notification_service.send_notification(
user=permission.applicant,
title="权限申请已通过",
content=f"您对知识库 '{permission.knowledge_base.name}' 的权限申请已通过",
notification_type="permission_approved",
related_object_id=str(permission.id)
)
# return permission
return permission
# def reject_permission(self, user, permission, response_message, notification_service):
# """拒绝权限申请"""
# if not self.can_manage_knowledge_base(user, permission.knowledge_base):
# raise ValidationError({
# 'code': 403,
# 'message': '只有知识库创建者可以审批此申请',
# 'data': None
# })
def reject_permission(self, user, permission, response_message, notification_service):
"""拒绝权限申请"""
if not self.can_manage_knowledge_base(user, permission.knowledge_base):
raise ValidationError({
'code': 403,
'message': '只有知识库创建者可以审批此申请',
'data': None
})
# if permission.status != 'pending':
# raise ValidationError({
# 'code': 400,
# 'message': '该申请已被处理',
# 'data': None
# })
if permission.status != 'pending':
raise ValidationError({
'code': 400,
'message': '该申请已被处理',
'data': None
})
# if not response_message:
# raise ValidationError({
# 'code': 400,
# 'message': '请填写拒绝原因',
# 'data': None
# })
if not response_message:
raise ValidationError({
'code': 400,
'message': '请填写拒绝原因',
'data': None
})
# with transaction.atomic():
# permission.status = 'rejected'
# permission.approver = user
# permission.response_message = response_message
# permission.save()
with transaction.atomic():
permission.status = 'rejected'
permission.approver = user
permission.response_message = response_message
permission.save()
# notification_service.send_notification(
# user=permission.applicant,
# title="权限申请已拒绝",
# content=f"您对知识库 '{permission.knowledge_base.name}' 的权限申请已被拒绝\n拒绝原因{response_message}",
# notification_type="permission_rejected",
# related_object_id=str(permission.id)
# )
notification_service.send_notification(
user=permission.applicant,
title="权限申请已拒绝",
content=f"您对知识库 '{permission.knowledge_base.name}' 的权限申请已被拒绝\n拒绝原因:{response_message}",
notification_type="permission_rejected",
related_object_id=str(permission.id)
)
# return permission
return permission
# def extend_permission(self, user, permission, new_expires_at, notification_service):
# """延长权限有效期"""
# if not self.check_extend_permission(permission, user):
# raise ValidationError({
# "code": 403,
# "message": "您没有权限延长此权限",
# "data": None
# })
def extend_permission(self, user, permission, new_expires_at, notification_service):
"""延长权限有效期"""
if not self.check_extend_permission(permission, user):
raise ValidationError({
"code": 403,
"message": "您没有权限延长此权限",
"data": None
})
# if not new_expires_at:
# raise ValidationError({
# "code": 400,
# "message": "请设置新的过期时间",
# "data": None
# })
if not new_expires_at:
raise ValidationError({
"code": 400,
"message": "请设置新的过期时间",
"data": None
})
# try:
# new_expires_at = timezone.datetime.strptime(new_expires_at, '%Y-%m-%dT%H:%M:%SZ')
# new_expires_at = timezone.make_aware(new_expires_at)
# if new_expires_at <= timezone.now():
# raise ValidationError({
# "code": 400,
# "message": "过期时间不能早于或等于当前时间",
# "data": None
# })
# except ValueError:
# raise ValidationError({
# "code": 400,
# "message": "过期时间格式错误,应为 ISO 格式 (YYYY-MM-DDThh:mm:ssZ)",
# "data": None
# })
try:
new_expires_at = timezone.datetime.strptime(new_expires_at, '%Y-%m-%dT%H:%M:%SZ')
new_expires_at = timezone.make_aware(new_expires_at)
if new_expires_at <= timezone.now():
raise ValidationError({
"code": 400,
"message": "过期时间不能早于或等于当前时间",
"data": None
})
except ValueError:
raise ValidationError({
"code": 400,
"message": "过期时间格式错误,应为 ISO 格式 (YYYY-MM-DDThh:mm:ssZ)",
"data": None
})
# with transaction.atomic():
# permission.expires_at = new_expires_at
# permission.save()
with transaction.atomic():
permission.expires_at = new_expires_at
permission.save()
# kb_permission = KBPermissionModel.objects.get(
# knowledge_base=permission.knowledge_base,
# user=permission.applicant
# )
# kb_permission.expires_at = new_expires_at
# kb_permission.save()
kb_permission = KBPermissionModel.objects.get(
knowledge_base=permission.knowledge_base,
user=permission.applicant
)
kb_permission.expires_at = new_expires_at
kb_permission.save()
# notification_service.send_notification(
# user=permission.applicant,
# title="权限有效期延长",
# content=f"您对知识库 '{permission.knowledge_base.name}' 的权限有效期已延长至 {new_expires_at.strftime('%Y-%m-%d %H:%M:%S')}",
# notification_type="permission_extended",
# related_object_id=str(permission.id)
# )
notification_service.send_notification(
user=permission.applicant,
title="权限有效期延长",
content=f"您对知识库 '{permission.knowledge_base.name}' 的权限有效期已延长至 {new_expires_at.strftime('%Y-%m-%d %H:%M:%S')}",
notification_type="permission_extended",
related_object_id=str(permission.id)
)
# return permission
return permission
# def update_user_permission(self, admin_user, user_id, knowledge_base_id, permissions, expires_at_str, notification_service):
# """管理员更新用户权限"""
# if admin_user.role != 'admin':
# raise ValidationError({
# 'code': 403,
# 'message': '只有管理员可以直接修改权限',
# 'data': None
# })
def update_user_permission(self, admin_user, user_id, knowledge_base_id, permissions, expires_at_str, notification_service):
"""管理员更新用户权限"""
if admin_user.role != 'admin':
raise ValidationError({
'code': 403,
'message': '只有管理员可以直接修改权限',
'data': None
})
# if not all([user_id, knowledge_base_id, permissions]):
# raise ValidationError({
# 'code': 400,
# 'message': '缺少必要参数',
# 'data': None
# })
if not all([user_id, knowledge_base_id, permissions]):
raise ValidationError({
'code': 400,
'message': '缺少必要参数',
'data': None
})
# required_permission_fields = ['can_read', 'can_edit', 'can_delete']
# if not all(field in permissions for field in required_permission_fields):
# raise ValidationError({
# 'code': 400,
# 'message': '权限参数格式错误,必须包含 can_read、can_edit、can_delete',
# 'data': None
# })
required_permission_fields = ['can_read', 'can_edit', 'can_delete']
if not all(field in permissions for field in required_permission_fields):
raise ValidationError({
'code': 400,
'message': '权限参数格式错误,必须包含 can_read、can_edit、can_delete',
'data': None
})
# try:
# user = User.objects.get(id=user_id)
# knowledge_base = KnowledgeBase.objects.get(id=knowledge_base_id)
# except User.DoesNotExist:
# raise ValidationError({
# 'code': 404,
# 'message': f'用户ID {user_id} 不存在',
# 'data': None
# })
# except KnowledgeBase.DoesNotExist:
# raise ValidationError({
# 'code': 404,
# 'message': f'知识库ID {knowledge_base_id} 不存在',
# 'data': None
# })
try:
user = User.objects.get(id=user_id)
knowledge_base = KnowledgeBase.objects.get(id=knowledge_base_id)
except User.DoesNotExist:
raise ValidationError({
'code': 404,
'message': f'用户ID {user_id} 不存在',
'data': None
})
except KnowledgeBase.DoesNotExist:
raise ValidationError({
'code': 404,
'message': f'知识库ID {knowledge_base_id} 不存在',
'data': None
})
# if knowledge_base.type == 'private' and str(knowledge_base.user_id) != str(user.id):
# raise ValidationError({
# 'code': 403,
# 'message': '不能修改其他用户的私有知识库权限',
# 'data': None
# })
if knowledge_base.type == 'private' and str(knowledge_base.user_id) != str(user.id):
raise ValidationError({
'code': 403,
'message': '不能修改其他用户的私有知识库权限',
'data': None
})
# if user.role == 'member' and permissions.get('can_delete'):
# raise ValidationError({
# 'code': 400,
# 'message': '普通成员不能获得删除权限',
# 'data': None
# })
if user.role == 'member' and permissions.get('can_delete'):
raise ValidationError({
'code': 400,
'message': '普通成员不能获得删除权限',
'data': None
})
# expires_at = None
# if expires_at_str:
# try:
# expires_at = timezone.datetime.strptime(expires_at_str, '%Y-%m-%dT%H:%M:%SZ')
# expires_at = timezone.make_aware(expires_at)
# if expires_at <= timezone.now():
# raise ValidationError({
# 'code': 400,
# 'message': '过期时间不能早于或等于当前时间',
# 'data': None
# })
# except ValueError:
# raise ValidationError({
# 'code': 400,
# 'message': '过期时间格式错误,应为 ISO 格式 (YYYY-MM-DDThh:mm:ssZ)',
# 'data': None
# })
expires_at = None
if expires_at_str:
try:
expires_at = timezone.datetime.strptime(expires_at_str, '%Y-%m-%dT%H:%M:%SZ')
expires_at = timezone.make_aware(expires_at)
if expires_at <= timezone.now():
raise ValidationError({
'code': 400,
'message': '过期时间不能早于或等于当前时间',
'data': None
})
except ValueError:
raise ValidationError({
'code': 400,
'message': '过期时间格式错误,应为 ISO 格式 (YYYY-MM-DDThh:mm:ssZ)',
'data': None
})
# with transaction.atomic():
# permission, created = KBPermissionModel.objects.update_or_create(
# user=user,
# knowledge_base=knowledge_base,
# defaults={
# 'can_read': permissions.get('can_read', False),
# 'can_edit': permissions.get('can_edit', False),
# 'can_delete': permissions.get('can_delete', False),
# 'granted_by': admin_user,
# 'status': 'active',
# 'expires_at': expires_at
# }
# )
with transaction.atomic():
permission, created = KBPermissionModel.objects.update_or_create(
user=user,
knowledge_base=knowledge_base,
defaults={
'can_read': permissions.get('can_read', False),
'can_edit': permissions.get('can_edit', False),
'can_delete': permissions.get('can_delete', False),
'granted_by': admin_user,
'status': 'active',
'expires_at': expires_at
}
)
# notification_service.send_notification(
# user=user,
# title="知识库权限更新",
# content=f"管理员已{created and '授予' or '更新'}您对知识库 '{knowledge_base.name}' 的权限",
# notification_type="permission_updated",
# related_object_id=str(permission.id)
# )
notification_service.send_notification(
user=user,
title="知识库权限更新",
content=f"管理员已{created and '授予' or '更新'}您对知识库 '{knowledge_base.name}' 的权限",
notification_type="permission_updated",
related_object_id=str(permission.id)
)
return permission, created
# return permission, created

View File

@ -1,15 +1,11 @@
import json
import re
import logging
import requests
from urllib.parse import urljoin, urlparse, parse_qs
from urllib.parse import urljoin
# 基础URL地址
BASE_API_URL = "https://open.feishu.cn/open-apis/"
# 获取日志记录器
logger = logging.getLogger(__name__)
class BitableService:
"""
@ -36,11 +32,6 @@ class BitableService:
if headers is None:
headers = {}
# 记录请求信息,避免记录敏感信息
logger.info(f"请求飞书API: {method} {full_url}")
if params:
logger.info(f"请求参数: {params}")
response = requests.request(
method=method,
url=full_url,
@ -52,29 +43,7 @@ class BitableService:
# 检查响应
if not response.ok:
error_msg = f"API 请求失败: {response.status_code}, 响应: {response.text}"
logger.error(error_msg)
# 解析错误响应
try:
error_json = response.json()
error_code = error_json.get("code")
error_msg = error_json.get("msg", "")
# 根据错误代码提供更具体的错误信息
if error_code == 91402: # NOTEXIST
error_detail = "请求的资源不存在请检查app_token和table_id是否正确以及应用是否有权限访问该资源"
logger.error(f"资源不存在错误: {error_detail}")
raise Exception(f"资源不存在: {error_detail}")
elif error_code == 99991663: # TOKEN_INVALID
error_detail = "访问令牌无效或已过期"
logger.error(f"令牌错误: {error_detail}")
raise Exception(f"访问令牌错误: {error_detail}")
else:
logger.error(f"飞书API错误: 代码={error_code}, 消息={error_msg}")
except ValueError:
# 响应不是有效的JSON
pass
print(error_msg)
raise Exception(error_msg)
return response.json()
@ -83,11 +52,6 @@ class BitableService:
def extract_params_from_url(table_url):
"""
从URL中提取app_token和table_id
支持多种飞书多维表格URL格式
1. https://xxx.feishu.cn/base/{app_token}?table={table_id}
2. https://xxx.feishu.cn/wiki/{app_token}?sheet={table_id}
3. https://xxx.feishu.cn/wiki/wikcnXXX?sheet=XXX
4. https://xxx.feishu.cn/base/bascnXXX?table=tblXXX
Args:
table_url: 飞书多维表格URL
@ -98,76 +62,13 @@ class BitableService:
Raises:
ValueError: 如果无法从URL中提取必要参数
"""
# 记录原始URL
logger.info(f"解析多维表格URL: {table_url}")
app_token_match = re.search(r'base/([^?]+)', table_url)
table_id_match = re.search(r'table=([^&]+)', table_url)
# 处理URL中的多余空格
table_url = table_url.strip()
if not app_token_match or not table_id_match:
raise ValueError("无法从URL中提取必要参数请确认URL格式正确")
# 解析URL
parsed_url = urlparse(table_url)
query_params = parse_qs(parsed_url.query)
path = parsed_url.path
logger.debug(f"URL路径: {path}, 查询参数: {query_params}")
# 尝试从查询参数中获取table_id
table_id = None
if 'table' in query_params:
table_id = query_params['table'][0].strip()
elif 'sheet' in query_params:
table_id = query_params['sheet'][0].strip()
# 尝试从路径中获取app_token
app_token = None
# 处理标准格式 /base/{app_token} 或 /wiki/{app_token}
standard_match = re.search(r'/(base|wiki)/([^/?]+)', path)
if standard_match:
app_token = standard_match.group(2).strip()
else:
# 处理简短ID格式如 /wiki/wikcnXXX
short_id_match = re.search(r'/(base|wiki)/([a-zA-Z0-9]+)', path)
if short_id_match:
app_token = short_id_match.group(2).strip()
# 检查是否成功提取
if not app_token or not table_id:
error_msg = "无法从URL中提取必要参数请确认URL格式正确"
logger.error(f"{error_msg}. URL: {table_url}")
raise ValueError(error_msg)
logger.info(f"成功从URL提取参数: app_token={app_token}, table_id={table_id}")
return app_token, table_id
@staticmethod
def validate_access(app_token, access_token):
"""
验证应用是否有权限访问多维表格
Args:
app_token: 应用令牌
access_token: 访问令牌
Returns:
bool: 是否有权限
"""
try:
# 构造请求
url = f"bitable/v1/apps/{app_token}"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json"
}
# 发送请求
response = BitableService.make_request("GET", url, headers=headers)
# 检查响应
return response and "code" in response and response["code"] == 0
except Exception as e:
logger.error(f"验证访问权限失败: {str(e)}")
return False
return app_token_match.group(1), table_id_match.group(1)
@staticmethod
def get_metadata(app_token, table_id, access_token):
@ -183,10 +84,6 @@ class BitableService:
dict: 表格元数据
"""
try:
# 先验证应用是否有权限访问
if not BitableService.validate_access(app_token, access_token):
logger.warning(f"应用无权限访问多维表格: app_token={app_token}")
# 构造请求
url = f"bitable/v1/apps/{app_token}/tables/{table_id}"
headers = {
@ -199,18 +96,16 @@ class BitableService:
# 检查响应
if response and "code" in response and response["code"] == 0:
metadata = response.get("data", {}).get("table", {})
logger.info(f"成功获取多维表格元数据: {metadata.get('name', table_id)}")
return metadata
return response.get("data", {}).get("table", {})
# 发生错误
error_msg = f"获取多维表格元数据失败: {json.dumps(response)}"
logger.error(error_msg)
print(error_msg)
raise Exception(error_msg)
except Exception as e:
# 如果正常API调用失败使用替代方法
logger.error(f"获取多维表格元数据失败: {str(e)}")
print(f"获取多维表格元数据失败: {str(e)}")
# 简单返回一个基本名称
return {
"name": f"table_{table_id}",
@ -238,8 +133,6 @@ class BitableService:
Exception: 查询失败时抛出异常
"""
try:
logger.info(f"查询多维表格记录: app_token={app_token}, table_id={table_id}")
# 构造请求URL
url = f"bitable/v1/apps/{app_token}/tables/{table_id}/records/search"
@ -266,18 +159,16 @@ class BitableService:
# 检查响应
if response and "code" in response and response["code"] == 0:
result = response.get("data", {})
logger.info(f"查询成功,获取到 {len(result.get('items', []))} 条记录")
return result
return response.get("data", {})
# 发生错误
error_msg = f"查询飞书多维表格失败: {json.dumps(response)}"
logger.error(error_msg)
print(error_msg)
raise Exception(error_msg)
except Exception as e:
# 记录详细错误
logger.error(f"查询飞书多维表格发生错误: {str(e)}")
print(f"查询飞书多维表格发生错误: {str(e)}")
raise
@staticmethod
@ -294,8 +185,6 @@ class BitableService:
list: 字段信息列表
"""
try:
logger.info(f"获取多维表格字段: app_token={app_token}, table_id={table_id}")
# 构造请求
url = f"bitable/v1/apps/{app_token}/tables/{table_id}/fields"
headers = {
@ -309,20 +198,17 @@ class BitableService:
# 检查响应
if response and "code" in response and response["code"] == 0:
fields = response.get("data", {}).get("items", [])
logger.info(f"成功获取到 {len(fields)} 个字段")
return fields
return response.get("data", {}).get("items", [])
# 发生错误
error_msg = f"获取多维表格字段失败: {json.dumps(response)}"
logger.error(error_msg)
print(error_msg)
raise Exception(error_msg)
except Exception as e:
# 记录详细错误
logger.error(f"获取字段信息失败: {str(e)}")
print(f"获取字段信息失败: {str(e)}")
# 如果获取失败,返回一个基本字段集
logger.warning("返回默认字段集")
return [
{"field_name": "title", "type": "text", "property": {}},
{"field_name": "description", "type": "text", "property": {}},

View File

@ -201,42 +201,12 @@ class DataSyncService:
dict: 同步结果
"""
try:
# 记录原始URL便于排查问题
logger.info(f"开始数据同步URL: {table_url}")
# 提取参数
try:
app_token, table_id = BitableService.extract_params_from_url(table_url)
logger.info(f"成功解析URL参数: app_token={app_token}, table_id={table_id}")
except ValueError as ve:
logger.error(f"URL解析失败: {str(ve)}, URL: {table_url}")
return {
'success': False,
'error': f"URL解析失败: {str(ve)}",
'details': "URL格式可能不正确请参考飞书多维表格文档"
}
# 验证应用是否有权限访问该多维表格
if not BitableService.validate_access(app_token, access_token):
error_msg = f"应用无权限访问多维表格: app_token={app_token}"
logger.warning(error_msg)
return {
'success': False,
'error': error_msg,
'details': "请确认access_token是否有效以及应用是否已被添加为表格协作者"
}
app_token, table_id = BitableService.extract_params_from_url(table_url)
# 1. 获取表格元数据
try:
metadata = BitableService.get_metadata(app_token, table_id, access_token)
feishu_table_name = metadata.get('name', f'table_{table_id}')
logger.info(f"获取到表格元数据: 名称={feishu_table_name}")
except Exception as e:
logger.error(f"获取表格元数据失败: {str(e)}")
return {
'success': False,
'error': f"获取表格元数据失败: {str(e)}"
}
metadata = BitableService.get_metadata(app_token, table_id, access_token)
feishu_table_name = metadata.get('name', f'table_{table_id}')
# 2. 获取或创建表格映射
if auto_sync:
@ -244,7 +214,6 @@ class DataSyncService:
try:
mapping = FeishuTableMapping.objects.get(app_token=app_token, table_id=table_id)
final_table_name = mapping.table_name
logger.info(f"使用已有映射: {final_table_name}")
except FeishuTableMapping.DoesNotExist:
# 如果找不到映射,自动创建一个
default_table_name = f"feishu_{feishu_table_name.lower().replace(' ', '_').replace('-', '_')}"
@ -256,7 +225,6 @@ class DataSyncService:
table_name or default_table_name
)
final_table_name = mapping.table_name
logger.info(f"创建新映射: {final_table_name}")
else:
# 非自动同步模式,优先使用用户提供的表名
default_table_name = f"feishu_{feishu_table_name.lower().replace(' ', '_').replace('-', '_')}"
@ -270,22 +238,12 @@ class DataSyncService:
feishu_table_name,
final_table_name
)
logger.info(f"使用表名: {final_table_name}")
# 3. 获取字段信息
try:
fields = BitableService.get_table_fields(app_token, table_id, access_token)
logger.info(f"获取到 {len(fields)} 个字段")
except Exception as e:
logger.error(f"获取字段信息失败: {str(e)}")
return {
'success': False,
'error': f"获取字段信息失败: {str(e)}"
}
fields = BitableService.get_table_fields(app_token, table_id, access_token)
# 4. 创建模型
model = DataSyncService.create_model_from_fields(final_table_name, fields)
logger.info(f"创建模型: {model.__name__}")
# 5. 检查表是否存在,不存在则创建
table_exists = DataSyncService.check_table_exists(final_table_name)
@ -300,92 +258,74 @@ class DataSyncService:
page_token = None
page_size = 100
try:
while True:
# 查询记录
result = BitableService.search_records(
app_token=app_token,
table_id=table_id,
access_token=access_token,
page_size=page_size,
page_token=page_token
)
while True:
# 查询记录
result = BitableService.search_records(
app_token=app_token,
table_id=table_id,
access_token=access_token,
page_size=page_size,
page_token=page_token
)
records = result.get('items', [])
all_records.extend(records)
records = result.get('items', [])
all_records.extend(records)
logger.info(f"获取第 {len(all_records) // page_size + 1} 页数据, 共 {len(records)} 条记录")
# 检查是否有更多数据
page_token = result.get('page_token')
if not page_token or not records:
break
except Exception as e:
logger.error(f"获取记录失败: {str(e)}")
return {
'success': False,
'error': f"获取记录失败: {str(e)}"
}
# 检查是否有更多数据
page_token = result.get('page_token')
if not page_token or not records:
break
# 7. 同步数据到数据库
try:
with transaction.atomic():
# 统计数据
created_count = 0
updated_count = 0
with transaction.atomic():
# 统计数据
created_count = 0
updated_count = 0
for record in all_records:
record_id = record.get('record_id')
fields_data = record.get('fields', {})
for record in all_records:
record_id = record.get('record_id')
fields_data = record.get('fields', {})
# 准备数据
data = {'feishu_record_id': record_id}
# 准备数据
data = {'feishu_record_id': record_id}
# 处理每个字段的数据
for field_name, field_value in fields_data.items():
# 将字段名转换为Python合法标识符
db_field_name = field_name.lower().replace(' ', '_').replace('-', '_')
# 处理每个字段的数据
for field_name, field_value in fields_data.items():
# 将字段名转换为Python合法标识符
db_field_name = field_name.lower().replace(' ', '_').replace('-', '_')
# 跳过已保留的字段名
if db_field_name in ['id', 'created_at', 'updated_at']:
continue
# 确保字段存在于模型中
if hasattr(model, db_field_name):
# 处理不同类型的字段值
if isinstance(field_value, (list, dict)):
data[db_field_name] = json.dumps(field_value)
else:
data[db_field_name] = field_value
# 尝试更新或创建记录
try:
# 总是使用 feishu_record_id 作为唯一标识符进行更新或创建
obj, created = model.objects.update_or_create(
feishu_record_id=record_id,
defaults=data
)
if created:
created_count += 1
else:
updated_count += 1
except Exception as e:
logger.error(f"更新或创建记录失败: {str(e)}, 记录ID: {record_id}")
# 跳过已保留的字段名
if db_field_name in ['id', 'created_at', 'updated_at']:
continue
# 更新映射表中的记录数
mapping.total_records = len(all_records)
mapping.save(update_fields=['total_records', 'last_sync_time'])
# 确保字段存在于模型中
if hasattr(model, db_field_name):
# 处理不同类型的字段值
if isinstance(field_value, (list, dict)):
data[db_field_name] = json.dumps(field_value)
else:
data[db_field_name] = field_value
logger.info(f"数据同步完成: 总记录数={len(all_records)}, 新增={created_count}, 更新={updated_count}")
except Exception as e:
logger.error(f"数据同步到数据库失败: {str(e)}")
return {
'success': False,
'error': f"数据同步到数据库失败: {str(e)}"
}
# 尝试更新或创建记录
try:
# 总是使用 feishu_record_id 作为唯一标识符进行更新或创建
obj, created = model.objects.update_or_create(
feishu_record_id=record_id,
defaults=data
)
if created:
created_count += 1
else:
updated_count += 1
except Exception as e:
logger.error(f"更新或创建记录失败: {str(e)}, 记录ID: {record_id}")
continue
# 更新映射表中的记录数
mapping.total_records = len(all_records)
mapping.save(update_fields=['total_records', 'last_sync_time'])
return {
'success': True,
@ -399,8 +339,8 @@ class DataSyncService:
}
except Exception as e:
logger.error(f"数据同步失败: {str(e)}", exc_info=True)
logger.error(f"数据同步失败: {str(e)}")
return {
'success': False,
'error': f"数据同步失败: {str(e)}"
'error': str(e)
}

View File

@ -0,0 +1 @@

View File

@ -22,6 +22,7 @@ from apps.chat.models import ChatHistory
from apps.knowledge_base.models import KnowledgeBase
from apps.user.authentication import CustomTokenAuthentication
logger = logging.getLogger(__name__)
@ -47,64 +48,23 @@ class FeishuTableRecordsView(APIView):
)
try:
# 记录原始URL以便于排查问题
logger.info(f"处理查询请求URL: {table_url}")
# 从URL中提取app_token和table_id
try:
app_token, table_id = BitableService.extract_params_from_url(table_url)
logger.info(f"成功解析URL参数: app_token={app_token}, table_id={table_id}")
except ValueError as ve:
logger.error(f"URL解析失败: {str(ve)}, URL: {table_url}")
return Response(
{"error": str(ve), "details": "URL格式可能不正确请参考飞书多维表格文档"},
status=status.HTTP_400_BAD_REQUEST
)
# 验证应用是否有权限访问该多维表格
if not BitableService.validate_access(app_token, access_token):
logger.warning(f"应用无权限访问多维表格: app_token={app_token}")
return Response(
{"error": "无权限访问该多维表格请确认access_token是否有效以及应用是否已被添加为表格协作者"},
status=status.HTTP_403_FORBIDDEN
)
app_token, table_id = BitableService.extract_params_from_url(table_url)
# 先获取一些样本数据,检查我们能否访问多维表格
try:
sample_data = BitableService.search_records(
app_token=app_token,
table_id=table_id,
access_token=access_token,
filter_exp=filter_exp,
sort=sort,
page_size=page_size,
page_token=page_token
)
sample_data = BitableService.search_records(
app_token=app_token,
table_id=table_id,
access_token=access_token,
filter_exp=filter_exp,
sort=sort,
page_size=page_size,
page_token=page_token
)
logger.info(f"成功获取多维表格数据,记录数: {len(sample_data.get('items', []))}")
return Response(sample_data, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"查询多维表格数据失败: {str(e)}")
# 提供更友好的错误信息
error_message = str(e)
if "资源不存在" in error_message:
return Response(
{"error": "表格资源不存在请检查URL是否正确以及应用是否有权限"},
status=status.HTTP_404_NOT_FOUND
)
elif "令牌错误" in error_message:
return Response(
{"error": "访问令牌无效或已过期,请重新获取"},
status=status.HTTP_401_UNAUTHORIZED
)
else:
return Response(
{"error": f"查询多维表格失败: {error_message}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
return Response(sample_data, status=status.HTTP_200_OK)
except ValueError as ve:
logger.error(f"URL解析或参数验证失败: {str(ve)}")
return Response(
{"error": str(ve), "details": "URL格式可能不正确"},
status=status.HTTP_400_BAD_REQUEST
@ -112,7 +72,6 @@ class FeishuTableRecordsView(APIView):
except Exception as e:
error_details = traceback.format_exc()
logger.error(f"查询飞书多维表格失败: {str(e)}\n{error_details}")
return Response(
{
"error": f"查询飞书多维表格失败: {str(e)}",
@ -143,77 +102,32 @@ class FeishuDataSyncView(APIView):
# 提取参数
try:
# 记录原始URL以便于排查问题
logger.info(f"处理数据同步请求URL: {table_url}")
# 从URL中提取app_token和table_id
try:
app_token, table_id = BitableService.extract_params_from_url(table_url)
logger.info(f"成功解析URL参数: app_token={app_token}, table_id={table_id}")
except ValueError as ve:
logger.error(f"URL解析失败: {str(ve)}, URL: {table_url}")
return Response(
{"error": str(ve), "details": "URL格式可能不正确请参考飞书多维表格文档"},
status=status.HTTP_400_BAD_REQUEST
)
# 验证应用是否有权限访问该多维表格
if not BitableService.validate_access(app_token, access_token):
logger.warning(f"应用无权限访问多维表格: app_token={app_token}")
return Response(
{"error": "无权限访问该多维表格请确认access_token是否有效以及应用是否已被添加为表格协作者"},
status=status.HTTP_403_FORBIDDEN
)
app_token, table_id = BitableService.extract_params_from_url(table_url)
# 先获取一些样本数据,检查我们能否访问多维表格
try:
sample_data = BitableService.search_records(
app_token=app_token,
table_id=table_id,
access_token=access_token,
page_size=5
)
sample_data = BitableService.search_records(
app_token=app_token,
table_id=table_id,
access_token=access_token,
page_size=5
)
logger.info(f"成功获取多维表格样本数据,记录数: {len(sample_data.get('items', []))}")
# 执行数据同步
result = DataSyncService.sync_data_to_db(
table_url=table_url,
access_token=access_token,
table_name=table_name,
primary_key=primary_key
)
# 执行数据同步
result = DataSyncService.sync_data_to_db(
table_url=table_url,
access_token=access_token,
table_name=table_name,
primary_key=primary_key
)
# 添加样本数据到结果中
if result.get('success'):
result['sample_data'] = sample_data.get('items', [])[:3] # 只返回最多3条样本数据
logger.info(f"数据同步成功: {result}")
return Response(result, status=status.HTTP_200_OK)
else:
logger.error(f"数据同步失败: {result.get('error')}")
return Response(result, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except Exception as e:
logger.error(f"数据同步过程失败: {str(e)}")
# 提供更友好的错误信息
error_message = str(e)
if "资源不存在" in error_message:
return Response(
{"error": "表格资源不存在请检查URL是否正确以及应用是否有权限"},
status=status.HTTP_404_NOT_FOUND
)
elif "令牌错误" in error_message:
return Response(
{"error": "访问令牌无效或已过期,请重新获取"},
status=status.HTTP_401_UNAUTHORIZED
)
else:
return Response(
{"error": f"数据同步失败: {error_message}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
# 添加样本数据到结果中
if result.get('success'):
result['sample_data'] = sample_data.get('items', [])[:3] # 只返回最多3条样本数据
return Response(result, status=status.HTTP_200_OK)
else:
return Response(result, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except ValueError as ve:
logger.error(f"URL解析或参数验证失败: {str(ve)}")
return Response(
{"error": str(ve), "details": "URL格式可能不正确"},
status=status.HTTP_400_BAD_REQUEST
@ -222,7 +136,6 @@ class FeishuDataSyncView(APIView):
except Exception as e:
import traceback
error_details = traceback.format_exc()
logger.error(f"数据同步失败: {str(e)}\n{error_details}")
return Response(
{
"error": f"数据同步失败: {str(e)}",

View File

@ -1,7 +1,7 @@
# Generated by Django 5.2.1 on 2025-05-28 08:45
# Generated by Django 5.2 on 2025-05-13 06:43
import django.db.models.deletion
import uuid
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
@ -19,16 +19,14 @@ class Migration(migrations.Migration):
name='GmailConversation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('conversation_id', models.CharField(help_text='Unique conversation identifier', max_length=100, unique=True)),
('user_email', models.EmailField(help_text="User's Gmail address", max_length=254)),
('influencer_email', models.EmailField(help_text="Influencer's email address", max_length=254)),
('title', models.CharField(help_text='Conversation title', max_length=255)),
('user_email', models.EmailField(help_text='用户Gmail邮箱', max_length=254)),
('influencer_email', models.EmailField(help_text='达人Gmail邮箱', max_length=254)),
('conversation_id', models.CharField(help_text='关联到chat_history的会话ID', max_length=100, unique=True)),
('title', models.CharField(default='Gmail对话', help_text='对话标题', max_length=100)),
('last_sync_time', models.DateTimeField(default=django.utils.timezone.now, help_text='最后同步时间')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('last_sync_time', models.DateTimeField(blank=True, help_text='Last time conversation was synced with Gmail', null=True)),
('is_active', models.BooleanField(default=True, help_text='Whether this conversation is active')),
('has_sent_greeting', models.BooleanField(default=False, help_text='Whether a greeting message has been sent to this conversation')),
('metadata', models.JSONField(blank=True, help_text='Additional metadata for the conversation', null=True)),
('is_active', models.BooleanField(default=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gmail_conversations', to=settings.AUTH_USER_MODEL)),
],
options={
@ -41,7 +39,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email_message_id', models.CharField(help_text='Gmail邮件ID', max_length=100)),
('attachment_id', models.TextField(help_text='Gmail附件的唯一标识符可能很长')),
('attachment_id', models.CharField(help_text='Gmail附件ID', max_length=100)),
('filename', models.CharField(help_text='原始文件名', max_length=255)),
('file_path', models.CharField(help_text='保存在服务器上的路径', max_length=255)),
('content_type', models.CharField(help_text='MIME类型', max_length=100)),
@ -55,107 +53,6 @@ class Migration(migrations.Migration):
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ConversationSummary',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('content', models.TextField(verbose_name='摘要内容')),
('last_message_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='最后处理的消息ID或ChatHistory的ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('conversation', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='summary', to='gmail.gmailconversation')),
],
options={
'verbose_name': 'Gmail对话摘要',
'verbose_name_plural': 'Gmail对话摘要',
'db_table': 'gmail_conversation_summaries',
},
),
migrations.CreateModel(
name='ProcessedPushNotification',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('message_id', models.CharField(help_text='Pub/Sub消息ID', max_length=255, unique=True)),
('email_address', models.EmailField(help_text='通知关联的Gmail邮箱', max_length=254)),
('history_id', models.CharField(help_text='Gmail历史ID', max_length=100)),
('processed_at', models.DateTimeField(auto_now_add=True, help_text='处理时间')),
('is_successful', models.BooleanField(default=True, help_text='处理是否成功')),
('metadata', models.JSONField(blank=True, default=dict, help_text='额外信息')),
],
options={
'verbose_name': '已处理推送通知',
'verbose_name_plural': '已处理推送通知',
'db_table': 'gmail_processed_push_notifications',
'ordering': ['-processed_at'],
'indexes': [models.Index(fields=['message_id'], name='gmail_proce_message_912a0c_idx'), models.Index(fields=['email_address', 'history_id'], name='gmail_proce_email_a_2e3770_idx')],
},
),
migrations.CreateModel(
name='UnmatchedEmail',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('message_id', models.CharField(help_text='Gmail邮件ID', max_length=255, unique=True)),
('user_id', models.CharField(help_text='用户ID (UUID字符串形式)', max_length=36)),
('user_email', models.EmailField(help_text='用户Gmail邮箱', max_length=254)),
('from_email', models.EmailField(help_text='发件人邮箱', max_length=254)),
('to_email', models.EmailField(help_text='收件人邮箱', max_length=254)),
('subject', models.CharField(blank=True, help_text='邮件主题', max_length=500)),
('processed_at', models.DateTimeField(auto_now_add=True, help_text='处理时间')),
],
options={
'verbose_name': '未匹配邮件',
'verbose_name_plural': '未匹配邮件',
'db_table': 'gmail_unmatched_emails',
'ordering': ['-processed_at'],
'indexes': [models.Index(fields=['message_id'], name='gmail_unmat_message_c1924d_idx'), models.Index(fields=['user_id'], name='gmail_unmat_user_id_ee06fe_idx'), models.Index(fields=['user_email', 'from_email'], name='gmail_unmat_user_em_a096af_idx')],
},
),
migrations.CreateModel(
name='UserGoal',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('description', models.TextField(verbose_name='目标描述')),
('status', models.CharField(choices=[('pending', '待处理'), ('in_progress', '进行中'), ('completed', '已完成'), ('failed', '失败')], default='pending', max_length=20, verbose_name='目标状态')),
('is_active', models.BooleanField(default=True, verbose_name='是否激活')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('completion_time', models.DateTimeField(blank=True, null=True, verbose_name='完成时间')),
('last_activity_time', models.DateTimeField(blank=True, null=True, verbose_name='最后活动时间')),
('metadata', models.JSONField(blank=True, default=dict, help_text='存储额外信息', null=True)),
('conversation', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='goals', to='gmail.gmailconversation')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='goals', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': '用户目标',
'verbose_name_plural': '用户目标',
'ordering': ['-updated_at'],
},
),
migrations.CreateModel(
name='AutoReplyConfig',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('user_email', models.EmailField(help_text='用户Gmail邮箱', max_length=254)),
('influencer_email', models.EmailField(help_text='达人Gmail邮箱', max_length=254)),
('is_enabled', models.BooleanField(default=True, help_text='是否启用自动回复')),
('goal_description', models.TextField(help_text='AI回复时参考的目标', verbose_name='自动回复的目标描述')),
('reply_template', models.TextField(blank=True, help_text='回复模板可选为空则由AI生成', null=True)),
('max_replies', models.IntegerField(default=5, help_text='最大自动回复次数')),
('current_replies', models.IntegerField(default=0, help_text='当前已自动回复次数')),
('last_reply_time', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('metadata', models.JSONField(blank=True, default=dict, help_text='存储额外信息如已处理的消息ID等', null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auto_reply_configs', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Gmail自动回复配置',
'verbose_name_plural': 'Gmail自动回复配置',
'db_table': 'gmail_auto_reply_configs',
'ordering': ['-updated_at'],
'unique_together': {('user', 'user_email', 'influencer_email')},
},
),
migrations.CreateModel(
name='GmailCredential',
fields=[
@ -166,7 +63,6 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_valid', models.BooleanField(default=True, help_text='Whether the credential is valid')),
('last_history_id', models.CharField(blank=True, help_text='Last processed Gmail history ID', max_length=50, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gmail_credentials', to=settings.AUTH_USER_MODEL)),
],
options={

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2 on 2025-05-13 08:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('gmail', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='gmailcredential',
name='last_history_id',
field=models.CharField(blank=True, help_text='Last processed Gmail history ID', max_length=50, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2 on 2025-05-13 09:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('gmail', '0002_gmailcredential_last_history_id'),
]
operations = [
migrations.AddField(
model_name='gmailconversation',
name='metadata',
field=models.JSONField(blank=True, default=dict, help_text='存储额外信息如已处理的消息ID等', null=True),
),
]

View File

@ -0,0 +1,31 @@
# Generated by Django 5.2 on 2025-05-13 10:11
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('gmail', '0003_gmailconversation_metadata'),
]
operations = [
migrations.CreateModel(
name='ConversationSummary',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('content', models.TextField(verbose_name='摘要内容')),
('last_message_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='最后处理的消息ID或ChatHistory的ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('conversation', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='summary', to='gmail.gmailconversation')),
],
options={
'verbose_name': 'Gmail对话摘要',
'verbose_name_plural': 'Gmail对话摘要',
'db_table': 'gmail_conversation_summaries',
},
),
]

View File

@ -0,0 +1,34 @@
# Generated by Django 5.2 on 2025-05-14 02:52
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('gmail', '0004_conversationsummary'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserGoal',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('description', models.TextField(verbose_name='目标描述')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_active', models.BooleanField(default=True, verbose_name='是否激活')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='goals', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': '用户目标',
'verbose_name_plural': '用户目标',
'db_table': 'user_goals',
'ordering': ['-updated_at'],
},
),
]

View File

@ -0,0 +1,39 @@
# Generated by Django 5.2 on 2025-05-14 09:45
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('gmail', '0005_usergoal'),
]
operations = [
migrations.AddField(
model_name='usergoal',
name='completion_time',
field=models.DateTimeField(blank=True, null=True, verbose_name='完成时间'),
),
migrations.AddField(
model_name='usergoal',
name='conversation',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='goals', to='gmail.gmailconversation'),
),
migrations.AddField(
model_name='usergoal',
name='last_activity_time',
field=models.DateTimeField(blank=True, null=True, verbose_name='最后活动时间'),
),
migrations.AddField(
model_name='usergoal',
name='metadata',
field=models.JSONField(blank=True, default=dict, help_text='存储额外信息', null=True),
),
migrations.AddField(
model_name='usergoal',
name='status',
field=models.CharField(choices=[('pending', '待处理'), ('in_progress', '进行中'), ('completed', '已完成'), ('failed', '失败')], default='pending', max_length=20, verbose_name='目标状态'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2 on 2025-05-19 07:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('gmail', '0006_usergoal_completion_time_usergoal_conversation_and_more'),
]
operations = [
migrations.AlterField(
model_name='gmailattachment',
name='attachment_id',
field=models.CharField(help_text='Gmail附件的唯一标识符可能很长', max_length=255),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2 on 2025-05-19 07:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('gmail', '0007_alter_gmailattachment_attachment_id'),
]
operations = [
migrations.AlterField(
model_name='gmailattachment',
name='attachment_id',
field=models.TextField(help_text='Gmail附件的唯一标识符可能很长'),
),
]

View File

@ -0,0 +1,82 @@
# Generated by Django 5.2 on 2025-05-20 06:52
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('gmail', '0008_alter_gmailattachment_attachment_id'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='gmailconversation',
name='has_sent_greeting',
field=models.BooleanField(default=False, help_text='Whether a greeting message has been sent to this conversation'),
),
migrations.AlterField(
model_name='gmailconversation',
name='conversation_id',
field=models.CharField(help_text='Unique conversation identifier', max_length=100, unique=True),
),
migrations.AlterField(
model_name='gmailconversation',
name='influencer_email',
field=models.EmailField(help_text="Influencer's email address", max_length=254),
),
migrations.AlterField(
model_name='gmailconversation',
name='is_active',
field=models.BooleanField(default=True, help_text='Whether this conversation is active'),
),
migrations.AlterField(
model_name='gmailconversation',
name='last_sync_time',
field=models.DateTimeField(blank=True, help_text='Last time conversation was synced with Gmail', null=True),
),
migrations.AlterField(
model_name='gmailconversation',
name='metadata',
field=models.JSONField(blank=True, help_text='Additional metadata for the conversation', null=True),
),
migrations.AlterField(
model_name='gmailconversation',
name='title',
field=models.CharField(help_text='Conversation title', max_length=255),
),
migrations.AlterField(
model_name='gmailconversation',
name='user_email',
field=models.EmailField(help_text="User's Gmail address", max_length=254),
),
migrations.CreateModel(
name='AutoReplyConfig',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('user_email', models.EmailField(help_text='用户Gmail邮箱', max_length=254)),
('influencer_email', models.EmailField(help_text='达人Gmail邮箱', max_length=254)),
('is_enabled', models.BooleanField(default=True, help_text='是否启用自动回复')),
('goal_description', models.TextField(help_text='AI回复时参考的目标', verbose_name='自动回复的目标描述')),
('reply_template', models.TextField(blank=True, help_text='回复模板可选为空则由AI生成', null=True)),
('max_replies', models.IntegerField(default=5, help_text='最大自动回复次数')),
('current_replies', models.IntegerField(default=0, help_text='当前已自动回复次数')),
('last_reply_time', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('metadata', models.JSONField(blank=True, default=dict, help_text='存储额外信息如已处理的消息ID等', null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auto_reply_configs', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Gmail自动回复配置',
'verbose_name_plural': 'Gmail自动回复配置',
'db_table': 'gmail_auto_reply_configs',
'ordering': ['-updated_at'],
'unique_together': {('user', 'user_email', 'influencer_email')},
},
),
]

View File

@ -0,0 +1,53 @@
# Generated by Django 5.2 on 2025-05-20 09:21
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('gmail', '0009_gmailconversation_has_sent_greeting_and_more'),
]
operations = [
migrations.CreateModel(
name='ProcessedPushNotification',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('message_id', models.CharField(help_text='Pub/Sub消息ID', max_length=255, unique=True)),
('email_address', models.EmailField(help_text='通知关联的Gmail邮箱', max_length=254)),
('history_id', models.CharField(help_text='Gmail历史ID', max_length=100)),
('processed_at', models.DateTimeField(auto_now_add=True, help_text='处理时间')),
('is_successful', models.BooleanField(default=True, help_text='处理是否成功')),
('metadata', models.JSONField(blank=True, default=dict, help_text='额外信息')),
],
options={
'verbose_name': '已处理推送通知',
'verbose_name_plural': '已处理推送通知',
'db_table': 'gmail_processed_push_notifications',
'ordering': ['-processed_at'],
'indexes': [models.Index(fields=['message_id'], name='gmail_proce_message_912a0c_idx'), models.Index(fields=['email_address', 'history_id'], name='gmail_proce_email_a_2e3770_idx')],
},
),
migrations.CreateModel(
name='UnmatchedEmail',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('message_id', models.CharField(help_text='Gmail邮件ID', max_length=255, unique=True)),
('user_id', models.IntegerField(help_text='用户ID')),
('user_email', models.EmailField(help_text='用户Gmail邮箱', max_length=254)),
('from_email', models.EmailField(help_text='发件人邮箱', max_length=254)),
('to_email', models.EmailField(help_text='收件人邮箱', max_length=254)),
('subject', models.CharField(blank=True, help_text='邮件主题', max_length=500)),
('processed_at', models.DateTimeField(auto_now_add=True, help_text='处理时间')),
],
options={
'verbose_name': '未匹配邮件',
'verbose_name_plural': '未匹配邮件',
'db_table': 'gmail_unmatched_emails',
'ordering': ['-processed_at'],
'indexes': [models.Index(fields=['message_id'], name='gmail_unmat_message_c1924d_idx'), models.Index(fields=['user_id'], name='gmail_unmat_user_id_ee06fe_idx'), models.Index(fields=['user_email', 'from_email'], name='gmail_unmat_user_em_a096af_idx')],
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2 on 2025-05-20 09:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('gmail', '0010_processedpushnotification_unmatchedemail'),
]
operations = [
migrations.AlterField(
model_name='unmatchedemail',
name='user_id',
field=models.CharField(help_text='用户ID (UUID字符串形式)', max_length=36),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.2 on 2025-05-21 04:22
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('gmail', '0011_alter_unmatchedemail_user_id'),
]
operations = [
migrations.AlterModelTable(
name='usergoal',
table=None,
),
]

View File

@ -17,6 +17,7 @@ from ..models import GmailCredential, GmailConversation, GmailAttachment, Conver
from apps.chat.models import ChatHistory
from apps.knowledge_base.models import KnowledgeBase
import requests
# from apps.common.services.notification_service import NotificationService
import threading
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText

View File

@ -1,6 +1,6 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from apps.gmail.views import (
from .views import (
GmailAuthInitiateView,
GmailAuthCompleteView,
GmailConversationView,

View File

@ -35,85 +35,34 @@ class GmailAuthInitiateView(APIView):
"""
API 视图用于启动 Gmail OAuth2 认证流程
"""
permission_classes = [IsAuthenticated]
authentication_classes = [CustomTokenAuthentication] # 限制访问,仅允许已认证用户
permission_classes = [IsAuthenticated] # 限制访问,仅允许已认证用户
authentication_classes = [CustomTokenAuthentication]
def post(self, request):
"""
处理 POST 请求启动 Gmail OAuth2 认证并返回授权 URL
支持两种方式提供客户端密钥:
1. 在请求体中提供client_secret_json字段
2. 上传名为client_secret_file的JSON文件
直接使用系统中已配置的客户端密钥文件
Args:
request: Django REST Framework 请求对象包含客户端密钥 JSON 数据或文件
request: Django REST Framework 请求对象
Returns:
Response: 包含授权 URL JSON 响应成功时或错误信息失败时
Status Codes:
200: 成功生成授权 URL
400: 请求数据无效
500: 服务器内部错误如认证服务失败
"""
logger.debug(f"Received auth initiate request: {request.data}")
logger.debug(f"Received auth initiate request from user {request.user.id}")
# 检查是否是文件上传方式
client_secret_json = None
if 'client_secret_file' in request.FILES:
try:
# 读取上传的JSON文件内容
client_secret_file = request.FILES['client_secret_file']
client_secret_json = json.loads(client_secret_file.read().decode('utf-8'))
logger.info(f"从上传文件读取到客户端密钥JSON")
except json.JSONDecodeError as e:
logger.error(f"解析客户端密钥JSON文件失败: {str(e)}")
return Response({
'code': 400,
'message': f'无效的JSON文件格式: {str(e)}',
'data': None
}, status=status.HTTP_400_BAD_REQUEST)
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)
# 如果不是文件上传则尝试从请求数据中提取JSON
if not client_secret_json:
serializer = GmailCredentialSerializer(data=request.data, context={'request': request})
if serializer.is_valid():
try:
# 从请求数据中提取客户端密钥 JSON
client_secret_json = serializer.validated_data['client_secret_json']
except Exception as e:
logger.error(f"未提供客户端密钥JSON: {str(e)}")
return Response({
'code': 400,
'message': '请提供client_secret_json字段或上传client_secret_file文件',
'data': None
}, status=status.HTTP_400_BAD_REQUEST)
else:
# 记录无效请求数据并返回错误响应
logger.warning(f"Invalid request data: {serializer.errors}")
return Response({
'code': 400,
'message': '请求数据无效请提供client_secret_json字段或上传client_secret_file文件',
'data': serializer.errors
}, status=status.HTTP_400_BAD_REQUEST)
# 如果此时仍然没有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)
# 直接使用系统中已配置的客户端密钥文件
client_secret_path = 'media/secret/client_secret_266164728215-v84lngbp3vgr4ulql01sqkg5vaigf4a5.apps.googleusercontent.com.json'
try:
# 读取客户端密钥文件
with open(client_secret_path, 'r') as f:
client_secret_json = json.load(f)
# 调用 GmailService 生成授权 URL
auth_url = GmailService.initiate_authentication(request.user, client_secret_json)
logger.info(f"Generated auth URL for user {request.user.id}")
@ -136,19 +85,16 @@ class GmailAuthCompleteView(APIView):
"""
API 视图用于完成 Gmail OAuth2 认证流程
"""
permission_classes = [IsAuthenticated]
authentication_classes = [CustomTokenAuthentication] # 限制访问,仅允许已认证用户
permission_classes = [IsAuthenticated] # 限制访问,仅允许已认证用户
authentication_classes = [CustomTokenAuthentication]
def post(self, request):
"""
处理 POST 请求使用授权代码完成 Gmail OAuth2 认证并保存凭证
支持两种方式提供客户端密钥:
1. 在请求体中提供client_secret_json字段
2. 上传名为client_secret_file的JSON文件
直接使用系统中已配置的客户端密钥文件
Args:
request: Django REST Framework 请求对象包含授权代码和客户端密钥 JSON 或文件
request: Django REST Framework 请求对象包含授权代码
Returns:
Response: 包含已保存凭证数据的 JSON 响应成功时或错误信息失败时
@ -158,72 +104,25 @@ class GmailAuthCompleteView(APIView):
400: 请求数据无效
500: 服务器内部错误如认证失败
"""
logger.debug(f"Received auth complete request: {request.data}")
logger.debug(f"Received auth complete request from user {request.user.id}")
# 检查是否是文件上传方式
client_secret_json = None
if 'client_secret_file' in request.FILES:
try:
# 读取上传的JSON文件内容
client_secret_file = request.FILES['client_secret_file']
client_secret_json = json.loads(client_secret_file.read().decode('utf-8'))
logger.info(f"从上传文件读取到客户端密钥JSON")
except json.JSONDecodeError as e:
logger.error(f"解析客户端密钥JSON文件失败: {str(e)}")
return Response({
'code': 400,
'message': f'无效的JSON文件格式: {str(e)}',
'data': None
}, status=status.HTTP_400_BAD_REQUEST)
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)
# 直接使用系统中已配置的客户端密钥文件
client_secret_path = 'media/secret/client_secret_266164728215-v84lngbp3vgr4ulql01sqkg5vaigf4a5.apps.googleusercontent.com.json'
# 获取授权,无论是哪种方式都需要
# 获取授权代码
auth_code = request.data.get('auth_code')
if not auth_code:
return Response({
'code': 400,
'message': '必须提供授权码',
'data': None
}, status=status.HTTP_400_BAD_REQUEST)
# 如果不是文件上传则尝试从请求数据中提取JSON
if not client_secret_json:
serializer = GmailCredentialSerializer(data=request.data, context={'request': request})
if serializer.is_valid():
try:
# 从请求数据中提取客户端密钥 JSON
client_secret_json = serializer.validated_data['client_secret_json']
except Exception as e:
logger.error(f"未提供客户端密钥JSON: {str(e)}")
return Response({
'code': 400,
'message': '请提供client_secret_json字段或上传client_secret_file文件',
'data': None
}, status=status.HTTP_400_BAD_REQUEST)
else:
# 记录无效请求数据并返回错误响应
logger.warning(f"Invalid request data: {serializer.errors}")
return Response({
'code': 400,
'message': '请求数据无效请提供client_secret_json字段或上传client_secret_file文件',
'data': serializer.errors
}, status=status.HTTP_400_BAD_REQUEST)
# 如果此时仍然没有client_secret_json返回错误
if not client_secret_json:
return Response({
'code': 400,
'message': '请提供client_secret_json字段或上传client_secret_file文件',
'message': '缺少必要的授权代码',
'data': None
}, status=status.HTTP_400_BAD_REQUEST)
try:
# 读取客户端密钥文件
with open(client_secret_path, 'r') as f:
client_secret_json = json.load(f)
# 完成认证并保存凭证
credential = GmailService.complete_authentication(request.user, auth_code, client_secret_json)
# 序列化凭证数据以返回
@ -525,8 +424,8 @@ class GmailConversationView(APIView):
"""
API视图用于获取和保存Gmail对话
"""
permission_classes = [IsAuthenticated]
authentication_classes = [CustomTokenAuthentication] # 限制访问,仅允许已认证用户
permission_classes = [IsAuthenticated] # 限制访问,仅允许已认证用户
authentication_classes = [CustomTokenAuthentication]
def post(self, request):
"""
@ -630,8 +529,8 @@ class GmailAttachmentListView(APIView):
"""
API视图用于获取Gmail附件列表
"""
permission_classes = [IsAuthenticated]
authentication_classes = [CustomTokenAuthentication] # 限制访问,仅允许已认证用户
permission_classes = [IsAuthenticated] # 限制访问,仅允许已认证用户
authentication_classes = [CustomTokenAuthentication]
def get(self, request, conversation_id=None):
"""
@ -679,8 +578,8 @@ class GmailPubSubView(APIView):
"""
API视图用于设置Gmail的Pub/Sub实时通知
"""
permission_classes = [IsAuthenticated]
authentication_classes = [CustomTokenAuthentication] # 限制访问,仅允许已认证用户
permission_classes = [IsAuthenticated] # 限制访问,仅允许已认证用户
authentication_classes = [CustomTokenAuthentication]
def post(self, request):
"""
@ -761,9 +660,9 @@ class GmailPubSubView(APIView):
credentials = request.user.gmail_credentials.filter(is_valid=True)
# 构建响应数据
user = []
accounts = []
for cred in credentials:
user.append({
accounts.append({
'id': cred.id,
'email': cred.email,
'is_default': cred.is_default
@ -772,7 +671,7 @@ class GmailPubSubView(APIView):
return Response({
'code': 200,
'message': '获取账户列表成功',
'data': {'user': user}
'data': {'accounts': accounts}
}, status=status.HTTP_200_OK)
@ -780,8 +679,8 @@ class GmailSendEmailView(APIView):
"""
API视图用于发送Gmail邮件支持附件
"""
permission_classes = [IsAuthenticated]
authentication_classes = [CustomTokenAuthentication] # 限制访问,仅允许已认证用户
permission_classes = [IsAuthenticated] # 限制访问,仅允许已认证用户
authentication_classes = [CustomTokenAuthentication]
def post(self, request):
"""
@ -915,9 +814,9 @@ class GmailSendEmailView(APIView):
credentials = request.user.gmail_credentials.filter(is_valid=True)
# 构建响应数据
user = []
accounts = []
for cred in credentials:
user.append({
accounts.append({
'id': cred.id,
'email': cred.email,
'is_default': cred.is_default
@ -926,7 +825,7 @@ class GmailSendEmailView(APIView):
return Response({
'code': 200,
'message': '获取账户列表成功',
'data': {'user': user}
'data': {'accounts': accounts}
}, status=status.HTTP_200_OK)
@ -1012,8 +911,8 @@ class GmailConversationSummaryView(APIView):
"""
API视图用于获取Gmail对话的总结
"""
permission_classes = [IsAuthenticated]
authentication_classes = [CustomTokenAuthentication] # 限制访问,仅允许已认证用户
permission_classes = [IsAuthenticated] # 限制访问,仅允许已认证用户
authentication_classes = [CustomTokenAuthentication]
def get(self, request, conversation_id=None):
"""
@ -1094,7 +993,8 @@ class GmailGoalView(APIView):
用户目标API - 支持用户为每个对话设置不同的目标
"""
permission_classes = [IsAuthenticated]
authentication_classes = [CustomTokenAuthentication] # 限制访问,仅允许已认证用户
authentication_classes = [CustomTokenAuthentication]
def get(self, request, conversation_id=None):
"""
获取用户的目标
@ -1172,6 +1072,13 @@ class GmailGoalView(APIView):
'data': None
}, status=status.HTTP_404_NOT_FOUND)
# 检查权限
# if conversation.user.id != request.user.id:
# return Response({
# 'code': 403,
# 'message': '无权限访问此对话',
# 'data': None
# }, status=status.HTTP_403_FORBIDDEN)
# 查找现有目标
existing_goal = UserGoal.objects.filter(
@ -1274,7 +1181,8 @@ class SimpleRecommendedReplyView(APIView):
通过conversation_id一键获取目标对话摘要和推荐回复
"""
permission_classes = [IsAuthenticated]
authentication_classes = [CustomTokenAuthentication] # 限制访问,仅允许已认证用户
authentication_classes = [CustomTokenAuthentication]
def post(self, request):
"""
直接通过conversation_id获取推荐回复
@ -1312,12 +1220,12 @@ class SimpleRecommendedReplyView(APIView):
'data': None
}, status=status.HTTP_404_NOT_FOUND)
if conversation.user != request.user:
return Response({
'code': 403,
'message': '无权限访问该对话',
'data': None
}, status=status.HTTP_403_FORBIDDEN)
# if conversation.user != request.user:
# return Response({
# 'code': 403,
# 'message': '无权限访问该对话',
# 'data': None
# }, status=status.HTTP_403_FORBIDDEN)
except Exception as e:
logger.error(f"查找对话失败: {str(e)}")
return Response({
@ -1399,8 +1307,8 @@ class GmailExportView(APIView):
"""
API视图用于导出已回复的达人Gmail列表为Excel文件
"""
permission_classes = [IsAuthenticated]
authentication_classes = [CustomTokenAuthentication] # 限制访问,仅允许已认证用户
permission_classes = [IsAuthenticated] # 限制访问,仅允许已认证用户
authentication_classes = [CustomTokenAuthentication]
def get(self, request, format=None):
"""

View File

@ -1,4 +1,4 @@
# Generated by Django 5.2.1 on 2025-05-28 08:33
# Generated by Django 5.2 on 2025-05-07 03:40
import django.db.models.deletion
import uuid
@ -31,7 +31,8 @@ class Migration(migrations.Migration):
('update_time', models.DateTimeField(auto_now=True)),
],
options={
'indexes': [models.Index(fields=['type'], name='knowledge_b_type_7a8dcc_idx'), models.Index(fields=['department'], name='knowledge_b_departm_8f1cf3_idx'), models.Index(fields=['group'], name='knowledge_b_group_ae45d4_idx')],
'db_table': 'knowledge_bases',
'indexes': [models.Index(fields=['type'], name='knowledge_b_type_0439e7_idx'), models.Index(fields=['department'], name='knowledge_b_departm_e739fd_idx'), models.Index(fields=['group'], name='knowledge_b_group_3dcf34_idx')],
},
),
migrations.CreateModel(
@ -50,7 +51,8 @@ class Migration(migrations.Migration):
options={
'verbose_name': '知识库文档',
'verbose_name_plural': '知识库文档',
'indexes': [models.Index(fields=['knowledge_base', 'status'], name='knowledge_b_knowled_6653e6_idx'), models.Index(fields=['document_id'], name='knowledge_b_documen_1fd896_idx'), models.Index(fields=['external_id'], name='knowledge_b_externa_ae1a97_idx')],
'db_table': 'knowledge_base_documents',
'indexes': [models.Index(fields=['knowledge_base', 'status'], name='knowledge_b_knowled_a4db1b_idx'), models.Index(fields=['document_id'], name='knowledge_b_documen_dab90f_idx'), models.Index(fields=['external_id'], name='knowledge_b_externa_b0060c_idx')],
'unique_together': {('knowledge_base', 'document_id')},
},
),

View File

@ -0,0 +1,51 @@
# Generated by Django 5.2 on 2025-05-21 04:30
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('knowledge_base', '0001_initial'),
]
operations = [
migrations.RenameIndex(
model_name='knowledgebase',
new_name='knowledge_b_type_7a8dcc_idx',
old_name='knowledge_b_type_0439e7_idx',
),
migrations.RenameIndex(
model_name='knowledgebase',
new_name='knowledge_b_departm_8f1cf3_idx',
old_name='knowledge_b_departm_e739fd_idx',
),
migrations.RenameIndex(
model_name='knowledgebase',
new_name='knowledge_b_group_ae45d4_idx',
old_name='knowledge_b_group_3dcf34_idx',
),
migrations.RenameIndex(
model_name='knowledgebasedocument',
new_name='knowledge_b_knowled_6653e6_idx',
old_name='knowledge_b_knowled_a4db1b_idx',
),
migrations.RenameIndex(
model_name='knowledgebasedocument',
new_name='knowledge_b_documen_1fd896_idx',
old_name='knowledge_b_documen_dab90f_idx',
),
migrations.RenameIndex(
model_name='knowledgebasedocument',
new_name='knowledge_b_externa_ae1a97_idx',
old_name='knowledge_b_externa_b0060c_idx',
),
migrations.AlterModelTable(
name='knowledgebase',
table=None,
),
migrations.AlterModelTable(
name='knowledgebasedocument',
table=None,
),
]

View File

@ -34,6 +34,34 @@ class KnowledgeBase(models.Model):
create_time = models.DateTimeField(auto_now_add=True)
update_time = models.DateTimeField(auto_now=True)
# def is_owner(self, user):
# """检查用户是否是所有者(通过权限表检查)"""
# from apps.permissions.models import KnowledgeBasePermission
# return str(user.id) == str(self.user_id) or KnowledgeBasePermission.objects.filter(
# knowledge_base=self,
# user=user,
# can_read=True,
# can_edit=True,
# can_delete=True,
# status='active'
# ).exists()
# def get_owners(self):
# """获取所有所有者(包括创建者和具有完整权限的用户)"""
# from apps.user.models import User
# from apps.permissions.models import KnowledgeBasePermission
# # 获取创建者
# owners = [self.user_id]
# # 获取具有完整权限的用户
# permission_owners = KnowledgeBasePermission.objects.filter(
# knowledge_base=self,
# can_read=True,
# can_edit=True,
# can_delete=True,
# status='active'
# ).values_list('user_id', flat=True)
# owners.extend(permission_owners)
# return User.objects.filter(id__in=set(owners))
def calculate_stats(self):
"""计算文档统计信息"""

View File

@ -0,0 +1,10 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from apps.knowledge_base.views import KnowledgeBaseViewSet
router = DefaultRouter()
router.register(r'', KnowledgeBaseViewSet, basename='knowledge-base') # 移除 'knowledge-bases'
urlpatterns = [
path('', include(router.urls)),
]

File diff suppressed because it is too large Load Diff