From 8815a451837bcf2531d10bc933c3e030ba8eb668 Mon Sep 17 00:00:00 2001 From: zhangzhanwei Date: Tue, 10 Feb 2026 15:12:17 +0800 Subject: [PATCH] feat: Chat record share link --- apps/application/api/application_chat_link.py | 69 +++++++++ .../migrations/0010_chatsharelink.py | 33 +++++ apps/application/models/application_chat.py | 15 ++ .../serializers/application_chat_link.py | 131 ++++++++++++++++++ apps/application/urls.py | 3 +- apps/application/views/__init__.py | 1 + .../views/application_chat_link.py | 56 ++++++++ apps/common/utils/chat_link_code.py | 48 +++++++ apps/locales/en_US/LC_MESSAGES/django.po | 20 ++- apps/locales/zh_CN/LC_MESSAGES/django.po | 20 ++- apps/locales/zh_Hant/LC_MESSAGES/django.po | 20 ++- 11 files changed, 412 insertions(+), 4 deletions(-) create mode 100644 apps/application/api/application_chat_link.py create mode 100644 apps/application/migrations/0010_chatsharelink.py create mode 100644 apps/application/serializers/application_chat_link.py create mode 100644 apps/application/views/application_chat_link.py create mode 100644 apps/common/utils/chat_link_code.py diff --git a/apps/application/api/application_chat_link.py b/apps/application/api/application_chat_link.py new file mode 100644 index 00000000000..f6e39f17795 --- /dev/null +++ b/apps/application/api/application_chat_link.py @@ -0,0 +1,69 @@ +""" + @project: MaxKB + @Author: niu + @file: application_chat_link.py + @date: 2026/2/9 16:59 + @desc: +""" +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter +from django.utils.translation import gettext_lazy as _ + +from application.serializers.application_chat_link import ChatRecordShareLinkRequestSerializer +from common.mixins.api_mixin import APIMixin +from common.result import DefaultResultSerializer + + +class ChatRecordLinkAPI(APIMixin): + @staticmethod + def get_response(): + return DefaultResultSerializer + + @staticmethod + def get_request(): + return ChatRecordShareLinkRequestSerializer + + @staticmethod + def get_parameters(): + return [ + OpenApiParameter( + name="workspace_id", + description="工作空间id", + type=OpenApiTypes.STR, + location='path', + required=True, + ), + OpenApiParameter( + name="application_id", + description="Application ID", + type=OpenApiTypes.STR, + location='path', + required=True, + ), + OpenApiParameter( + name="chat_id", + description=_("Chat ID"), + type=OpenApiTypes.STR, + location='path', + required=True, + ), + ] + +class ChatRecordDetailShareAPI(APIMixin): + @staticmethod + def get_response(): + return DefaultResultSerializer + + + + @staticmethod + def get_parameters(): + return [ + OpenApiParameter( + name="link", + description="链接", + type=OpenApiTypes.STR, + location='path', + required=True, + ) + ] \ No newline at end of file diff --git a/apps/application/migrations/0010_chatsharelink.py b/apps/application/migrations/0010_chatsharelink.py new file mode 100644 index 00000000000..0dd392a2812 --- /dev/null +++ b/apps/application/migrations/0010_chatsharelink.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.9 on 2026-02-09 02:39 + +import django.contrib.postgres.fields +import django.db.models.deletion +import uuid_utils.compat +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('application', '0009_clean_application_knowledge_mapping'), + ('users', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ChatShareLink', + fields=[ + ('create_time', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, db_index=True, verbose_name='修改时间')), + ('id', models.UUIDField(default=uuid_utils.compat.uuid7, editable=False, primary_key=True, serialize=False, verbose_name='主键id')), + ('share_type', models.CharField(choices=[('PUBLIC', 'public'), ('PRIVATE', 'private')], default='PUBLIC', max_length=20)), + ('chat_record_ids', django.contrib.postgres.fields.ArrayField(base_field=models.UUIDField(), size=None)), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='application.application')), + ('chat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='application.chat')), + ('user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.SET_NULL, to='users.user')), + ], + options={ + 'db_table': 'application_chat_share_link', + }, + ), + ] diff --git a/apps/application/models/application_chat.py b/apps/application/models/application_chat.py index dd45b4b8bdd..bda07f9700e 100644 --- a/apps/application/models/application_chat.py +++ b/apps/application/models/application_chat.py @@ -15,6 +15,7 @@ from application.models import Application from common.encoder.encoder import SystemEncoder from common.mixins.app_model_mixin import AppModelMixin +from users.models import User class ChatUserType(models.TextChoices): @@ -64,6 +65,9 @@ class VoteReasonChoices(models.TextChoices): INCOMPLETE = 'incomplete', '内容不完善' OTHER = 'other', '其他' +class ShareLinkType(models.TextChoices): + PUBLIC = "PUBLIC", 'public' + PRIVATE = "PRIVATE", 'private' class ChatSourceChoices(models.TextChoices): ONLINE = "ONLINE", "线上使用" @@ -138,3 +142,14 @@ class Meta: indexes = [ models.Index(fields=['application_id', 'chat_user_id']), ] + +class ChatShareLink(AppModelMixin): + id = models.UUIDField(primary_key=True, max_length=128, default=uuid.uuid7, editable=False, verbose_name="主键id") + chat = models.ForeignKey(Chat, on_delete=models.CASCADE) + application = models.ForeignKey(Application,on_delete=models.CASCADE) + share_type = models.CharField(max_length=20, choices=ShareLinkType.choices, default=ShareLinkType.PUBLIC) + user = models.ForeignKey(User, on_delete=models.SET_NULL, db_constraint=False, blank=True, null=True) + chat_record_ids = ArrayField(base_field=models.UUIDField(max_length=128)) + + class Meta: + db_table = "application_chat_share_link" \ No newline at end of file diff --git a/apps/application/serializers/application_chat_link.py b/apps/application/serializers/application_chat_link.py new file mode 100644 index 00000000000..2309e8e5249 --- /dev/null +++ b/apps/application/serializers/application_chat_link.py @@ -0,0 +1,131 @@ +""" + @project: MaxKB + @Author: niu + @file: application_chat_link.py + @date: 2026/2/9 10:50 + @desc: +""" +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from application.models import Chat, ChatShareLink, ShareLinkType, ChatRecord +from common.exception.app_exception import AppApiException +from common.utils.chat_link_code import UUIDEncoder +import uuid_utils.compat as uuid + + +class ShareChatRecordModelSerializer(serializers.ModelSerializer): + class Meta: + model = ChatRecord + fields = ['id', 'problem_text', 'answer_text', 'answer_text_list', 'create_time'] + +class ChatRecordShareLinkRequestSerializer(serializers.Serializer): + chat_record_ids = serializers.ListSerializer( + child=serializers.UUIDField(), + required=False, + allow_empty=False, + label=_("Chat record IDs") + ) + is_current_all = serializers.BooleanField(required=False, default=False) + + def validate(self, attrs): + if not attrs.get('is_current_all') and not attrs.get('chat_record_ids'): + raise serializers.ValidationError(_('Chat record ids can not be empty')) + return attrs + +class ChatRecordShareLinkSerializer(serializers.Serializer): + chat_id = serializers.UUIDField(required=True, label=_("Conversation ID")) + workspace_id = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Workspace ID")) + application_id = serializers.UUIDField(required=True, label=_("Application ID")) + user_id = serializers.UUIDField(required=False, label=_("User ID")) + + def is_valid(self, *, raise_exception=False): + super().is_valid(raise_exception=True) + chat_id = self.data.get('chat_id') + application_id = self.data.get('application_id') + + chat_query_set = Chat.objects.filter(id=chat_id, application_id=application_id, is_deleted=False) + if not chat_query_set.exists(): + raise AppApiException(500, _('Chat id does not exist')) + + def generate_link(self, instance, with_valid=True): + if with_valid: + request_serializer = ChatRecordShareLinkRequestSerializer(data=instance) + request_serializer.is_valid(raise_exception=True) + self.is_valid(raise_exception=True) + if not instance.get('is_current_all', False): + chat_record_ids: list[str] = instance.get('chat_record_ids') + + record_count = ChatRecord.objects.filter(id__in=chat_record_ids, chat_id=self.data.get('chat_id')).count() + if record_count != len(chat_record_ids): + raise AppApiException(500, _('Invalid chat record ids')) + chat_id = self.data.get('chat_id') + application_id = self.data.get('application_id') + user_id = self.data.get('user_id') + + is_current_all = instance.get('is_current_all', False) + if is_current_all: + sorted_ids = list( + ChatRecord.objects.filter(chat_id=chat_id).order_by('create_time').values_list('id',flat=True) + ) + else: + chat_record_ids: list[str] = instance.get('chat_record_ids') + sorted_ids = list(ChatRecord.objects.filter(id__in=chat_record_ids).order_by('create_time').values_list('id',flat=True)) + + existing = ChatShareLink.objects.filter( + chat_id=chat_id, application_id=application_id, + share_type=ShareLinkType.PUBLIC, + user_id=user_id, + chat_record_ids=sorted_ids + ).first() + + if existing: + return {'link': UUIDEncoder.encode(existing.id)} + + chat_share_link_model = ChatShareLink( + id=uuid.uuid7(), + chat_id=chat_id, + application_id=application_id, + share_type=ShareLinkType.PUBLIC, + user_id=user_id, + chat_record_ids=sorted_ids + ) + chat_share_link_model.save() + + link = UUIDEncoder.encode(chat_share_link_model.id) + + return {'link': link} + + +class ChatShareLinkDetailSerializer(serializers.Serializer): + link = serializers.CharField(required=True, label=_("Link")) + + def is_valid(self, *, raise_exception=False): + super().is_valid(raise_exception=True) + + link = self.data.get('link') + share_link_id = UUIDEncoder.decode_to_str(link) + + share_link_query_set = ChatShareLink.objects.filter(id=share_link_id).first() + if not share_link_query_set: + raise AppApiException(500, _('Share link does not exist')) + if share_link_query_set.chat.is_deleted: + raise AppApiException(500, _('Chat has been deleted')) + + return share_link_query_set + + def get_record_list(self): + share_link_model = self.is_valid(raise_exception=True) + + chat_record_model_list = ChatRecord.objects.filter(id__in=share_link_model.chat_record_ids, + chat_id=share_link_model.chat_id).order_by('create_time') + + abstract = Chat.objects.filter( + id=share_link_model.chat_id + ).values_list('abstract', flat=True).first() + chat_record_list = ShareChatRecordModelSerializer(chat_record_model_list, many=True).data + + return { + 'abstract': abstract, + 'chat_record_list': chat_record_list + } diff --git a/apps/application/urls.py b/apps/application/urls.py index 75024766c0a..a7a683415a7 100644 --- a/apps/application/urls.py +++ b/apps/application/urls.py @@ -24,6 +24,7 @@ path('workspace//application//chat', views.ApplicationChat.as_view()), path('workspace//application//chat/export', views.ApplicationChat.Export.as_view()), path('workspace//application//chat//', views.ApplicationChat.Page.as_view()), + path('workspace//application//chat//share_chat', views.ChatRecordLinkView.as_view()), path('workspace//application//chat//chat_record', views.ApplicationChatRecord.as_view()), path('workspace//application//chat//chat_record/', views.ApplicationChatRecordOperateAPI.as_view()), path('workspace//application//chat//chat_record//', views.ApplicationChatRecord.Page.as_view()), @@ -39,5 +40,5 @@ path('workspace//application//mcp_tools', views.McpServers.as_view()), path('workspace//application//model//prompt_generate', views.PromptGenerateView.as_view()), path('chat_message/', views.ChatView.as_view()), - + path('chat/share/', views.ChatRecordDetailView.as_view()), ] diff --git a/apps/application/views/__init__.py b/apps/application/views/__init__.py index 81c9f1581d1..dfad7b9773f 100644 --- a/apps/application/views/__init__.py +++ b/apps/application/views/__init__.py @@ -13,3 +13,4 @@ from .application_stats import * from .application_chat import * from .application_chat_record import * +from .application_chat_link import * \ No newline at end of file diff --git a/apps/application/views/application_chat_link.py b/apps/application/views/application_chat_link.py new file mode 100644 index 00000000000..b96cce5e455 --- /dev/null +++ b/apps/application/views/application_chat_link.py @@ -0,0 +1,56 @@ +""" + @project: MaxKB + @Author: niu + @file: application_chat_link.py + @date: 2026/2/9 10:44 + @desc: +""" +from django.utils.translation import gettext_lazy as _ +from drf_spectacular.utils import extend_schema +from rest_framework.request import Request +from rest_framework.views import APIView + +from application.api.application_chat_link import ChatRecordLinkAPI, ChatRecordDetailShareAPI +from application.serializers.application_chat_link import ChatRecordShareLinkSerializer, ChatShareLinkDetailSerializer +from common import result +from common.auth import ChatTokenAuth + + +class ChatRecordLinkView(APIView): + authentication_classes = [ChatTokenAuth] + + @extend_schema( + methods=['POST'], + description=_("Generate share link"), + summary=_("Generate share link"), + operation_id=_("Generate share link"), # type: ignore + request=ChatRecordLinkAPI.get_request(), + parameters=ChatRecordLinkAPI.get_parameters(), + responses=ChatRecordLinkAPI.get_response(), + tags=[_("Chat record link")] # type: ignore + ) + + def post(self, request: Request, workspace_id: str, application_id: str, chat_id: str): + return result.success(ChatRecordShareLinkSerializer(data={ + "workspace_id": workspace_id, + "application_id": application_id, + "chat_id": chat_id, + "user_id": request.auth.chat_user_id + }).generate_link(request.data)) + + +class ChatRecordDetailView(APIView): + + @extend_schema( + methods=['GET'], + description=_("Get chat record by share link"), + summary=_("Get chat record by share link"), + operation_id=_("Get chat record by share link"), # type: ignore + parameters=ChatRecordDetailShareAPI.get_parameters(), + responses=ChatRecordDetailShareAPI.get_response(), + tags=[_("Chat record link")] # type: ignore + ) + def get(self, request, link: str): + return result.success( + ChatShareLinkDetailSerializer(data={'link':link}).get_record_list() + ) diff --git a/apps/common/utils/chat_link_code.py b/apps/common/utils/chat_link_code.py new file mode 100644 index 00000000000..96017301606 --- /dev/null +++ b/apps/common/utils/chat_link_code.py @@ -0,0 +1,48 @@ +""" + @project: MaxKB + @Author: niu + @file: chat_link_code.py + @date: 2026/2/9 11:31 + @desc: +""" +from typing import Union + +import uuid_utils.compat as uuid + + +class UUIDEncoder: + + BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + BASE62_LEN = 62 + + @staticmethod + def encode(uuid_obj: Union[uuid.UUID, str] = None) -> str: + + if uuid_obj is None: + uuid_obj = uuid.uuid7() + elif isinstance(uuid_obj, str): + uuid_obj = uuid.UUID(uuid_obj) + + num = int(uuid_obj.hex, 16) + + if num == 0: + return UUIDEncoder.BASE62_ALPHABET[0] + + result = [] + while num: + num, rem = divmod(num,62) + result.append(UUIDEncoder.BASE62_ALPHABET[rem]) + return ''.join(reversed(result)) + + @staticmethod + def decode(encoded: str) -> uuid.UUID: + + num = 0 + for char in encoded: + num = num * UUIDEncoder.BASE62_LEN + UUIDEncoder.BASE62_ALPHABET.index(char) + + return uuid.UUID(int=num) + + @staticmethod + def decode_to_str(encoded: str) -> str: + return str(UUIDEncoder.decode(encoded)) \ No newline at end of file diff --git a/apps/locales/en_US/LC_MESSAGES/django.po b/apps/locales/en_US/LC_MESSAGES/django.po index 613dce90cc4..c2049024620 100644 --- a/apps/locales/en_US/LC_MESSAGES/django.po +++ b/apps/locales/en_US/LC_MESSAGES/django.po @@ -9135,4 +9135,22 @@ msgid "WORKSPACE_MANAGE" msgstr "Workspace Manager" msgid "USER" -msgstr "Regular User" \ No newline at end of file +msgstr "Regular User" + +msgid "Generate share link" +msgstr "Generate share link" + +msgid "Chat record link" +msgstr "Chat record link" + +msgid "Get chat record by share link" +msgstr "Get chat record by share link" + +msgid "Invalid chat record ids" +msgstr "Invalid chat record ids" + +msgid "Share link does not exist" +msgstr "Share link does not exist" + +msgid "Chat has been deleted" +msgstr "Chat has been deleted" \ No newline at end of file diff --git a/apps/locales/zh_CN/LC_MESSAGES/django.po b/apps/locales/zh_CN/LC_MESSAGES/django.po index a388f2959f8..44d63854524 100644 --- a/apps/locales/zh_CN/LC_MESSAGES/django.po +++ b/apps/locales/zh_CN/LC_MESSAGES/django.po @@ -9258,4 +9258,22 @@ msgid "WORKSPACE_MANAGE" msgstr "空间管理员" msgid "USER" -msgstr "普通用户" \ No newline at end of file +msgstr "普通用户" + +msgid "Generate share link" +msgstr "生成分享链接" + +msgid "Chat record link" +msgstr "聊天记录链接" + +msgid "Get chat record by share link" +msgstr "通过分享链接获取聊天记录" + +msgid "Invalid chat record ids" +msgstr "无效的聊天记录ID" + +msgid "Share link does not exist" +msgstr "分享链接不存在" + +msgid "Chat has been deleted" +msgstr "聊天记录已被删除" \ No newline at end of file diff --git a/apps/locales/zh_Hant/LC_MESSAGES/django.po b/apps/locales/zh_Hant/LC_MESSAGES/django.po index 211e067fb70..05ad7b47f77 100644 --- a/apps/locales/zh_Hant/LC_MESSAGES/django.po +++ b/apps/locales/zh_Hant/LC_MESSAGES/django.po @@ -9255,4 +9255,22 @@ msgid "WORKSPACE_MANAGE" msgstr "空間管理員" msgid "USER" -msgstr "普通用戶" \ No newline at end of file +msgstr "普通用戶" + +msgid "Generate share link" +msgstr "產生分享連結" + +msgid "Chat record link" +msgstr "聊天記錄連結" + +msgid "Get chat record by share link" +msgstr "透過分享連結取得聊天記錄" + +msgid "Invalid chat record ids" +msgstr "無效的聊天記錄ID" + +msgid "Share link does not exist" +msgstr "分享連結不存在" + +msgid "Chat has been deleted" +msgstr "聊天記錄已被刪除" \ No newline at end of file