diff --git a/apps/chat/serializers/chat_authentication.py b/apps/chat/serializers/chat_authentication.py index 9ebe7b2a1cb..502736e1611 100644 --- a/apps/chat/serializers/chat_authentication.py +++ b/apps/chat/serializers/chat_authentication.py @@ -41,6 +41,34 @@ def auth(self, request): _type).to_token() +class AnonymousAuthenticationV2Serializer(serializers.Serializer): + """v2 匿名认证:application_id 不在 path,从 access_token 解出并写进 token, + 供 ChatUserToken handler 收窄到该应用。""" + access_token = serializers.CharField(required=True, label=_("access_token")) + + def auth(self, request, with_valid=True): + token = request.META.get('HTTP_AUTHORIZATION') + token_details = {} + try: + # 校验token + if token is not None: + token_details = signing.loads(token[7:]) + except Exception as e: + pass + if with_valid: + self.is_valid(raise_exception=True) + access_token = self.data.get("access_token") + application_access_token = QuerySet(ApplicationAccessToken).filter(access_token=access_token).first() + if application_access_token is None or not application_access_token.is_active: + raise NotFound404(404, _("Invalid access_token")) + chat_user_id = token_details.get('user_id') or token_details.get('id') or str(uuid.uuid7()) + _type = AuthenticationType.CHAT_USER + application_id = str(application_access_token.application_id) + return ChatToken(chat_user_id, _type, str(Operate.ANNOTATION_AUTH), + application_id=application_id).to_token(), \ + FileToken(chat_user_id, _type, application_id=application_id).to_token() + + class AuthProfileSerializer(serializers.Serializer): access_token = serializers.CharField(required=True, label=_("access_token")) diff --git a/apps/chat/urls.py b/apps/chat/urls.py index 041d1467e20..05c07ad9188 100644 --- a/apps/chat/urls.py +++ b/apps/chat/urls.py @@ -1,40 +1,72 @@ -from django.urls import path +from django.urls import path, include from application.views import ChatRecordDetailView, ChatRecordLinkView -from chat.views.mcp import mcp_view -from . import views +from chat.views import v2 as v2_views, v3 as v3_views app_name = 'chat' # @formatter:off # fmt: off -urlpatterns = [ - path('embed', views.ChatEmbedView.as_view()), - path('mcp', mcp_view), - path('auth/anonymous', views.AnonymousAuthentication.as_view()), - path('profile', views.AuthProfile.as_view()), - path('application//profile', views.ApplicationProfile.as_view(), name='profile'), - path('chat_message/', views.ChatView.as_view(), name='chat'), - path('chat_message//cancel', views.CancelWorkflowView.as_view(), name='cancel_workflow'), - path('application//open', views.OpenView.as_view(), name='open'), - path('text_to_speech', views.TextToSpeech.as_view()), - path('speech_to_text', views.SpeechToText.as_view()), - path('captcha', views.CaptchaView.as_view(), name='captcha'), - path('/chat/completions', views.OpenAIView.as_view(), name='application/chat_completions'), - path('vote/chat//chat_record/', views.VoteView.as_view(), name='vote'), - path('historical_conversation', views.HistoricalConversationView.as_view(), name='historical_conversation'), - path('historical_conversation//record/',views.ChatRecordView.as_view(),name='conversation_details'), - path('historical_conversation//', views.HistoricalConversationView.PageView.as_view(), name='historical_conversation'), - path('historical_conversation/clear',views.HistoricalConversationView.BatchDelete.as_view(), name='historical_conversation_clear'), - path('historical_conversation/',views.HistoricalConversationView.Operate.as_view(), name='historical_conversation_operate'), - path('historical_conversation_record/', views.HistoricalConversationRecordView.as_view(), name='historical_conversation_record'), - path('historical_conversation_record///', views.HistoricalConversationRecordView.PageView.as_view(), name='historical_conversation_record'), + +v3=[ + # ---- application 作用域:application_id 从 path 获取 ---- + path('application//', include([ + path("profile",v3_views.ApplicationProfile.as_view(), name='v3_profile'), + path('open', v3_views.OpenView.as_view(), name='v3_open'), + path('text_to_speech',v3_views.TextToSpeech.as_view(),name='v3_text_to_speech'), + path('speech_to_text',v3_views.SpeechToText.as_view(),name='v3_speech_to_text'), + path('chat/completions',v3_views.OpenAIView.as_view(), name='v3_chat_completions'), + path('chat/clear',v3_views.HistoricalConversationView.BatchDelete.as_view(), name='v3_historical_conversation_clear'), + path('chat',v3_views.HistoricalConversationView.as_view(), name='v3_historical_conversation'), + path('chat//',v3_views.HistoricalConversationView.PageView.as_view(),name='v3_historical_conversation_page'), + path('chat//chat_message',v3_views.ChatView.as_view(), name='v3_chat'), + path('chat//chat_record',v3_views.HistoricalConversationRecordView.as_view(), name='v3_historical_conversation_record'), + path('chat//chat_record//', v3_views.HistoricalConversationRecordView.PageView.as_view(), name='v3_historical_conversation_record_page'), + path('chat//chat_record/',v3_views.ChatRecordView.as_view(),name='v3_conversation_details'), + path('chat//chat_record//vote',v3_views.VoteView.as_view(), name='v3_vote'), + path('chat//share_chat',ChatRecordLinkView.as_view(),name='v3_share_chat'), + path('chat/',v3_views.HistoricalConversationView.Operate.as_view(), name='v3_historical_conversation_operate'), +])), + # ---- 全局(非 application 作用域)---- + path('embed', v3_views.ChatEmbedView.as_view()), + path('mcp', v3_views.mcp_view), + path('auth/anonymous', v3_views.AnonymousAuthentication.as_view()), + path('auth/login/', v3_views.LocalLoginView.as_view()), + path('auth/logout', v3_views.Logout.as_view(), name='v3_logout'), + path('profile', v3_views.AuthProfile.as_view()), + path('captcha', v3_views.CaptchaView.as_view(), name='v3_captcha'), + path('share/', ChatRecordDetailView.as_view()), + path('chat_message//cancel', v3_views.CancelWorkflowView.as_view(), name='v3_cancel_workflow'), + path('chat_user/profile', v3_views.ChatUserProfileView.as_view(), name='v3_chat_user_profile'), + path('chat_user/current/reset_password', v3_views.ResetCurrentUserPasswordView.as_view(), name='v3_reset_password_current'), + path('api_key', v3_views.ChatUserApiKeyView.as_view()), + path('api_key//', v3_views.ChatUserApiKeyView.Page.as_view()), + path('api_key/', v3_views.ChatUserApiKeyView.Operate.as_view()), +] +v2=[ + path('embed', v2_views.ChatEmbedView.as_view()), + path('mcp', v2_views.mcp_view), + path('auth/anonymous', v2_views.AnonymousAuthentication.as_view(), name='anonymous'), + path('profile', v2_views.AuthProfile.as_view()), + path('application/profile', v2_views.ApplicationProfile.as_view(), name='profile'), + path('chat_message/', v2_views.ChatView.as_view(), name='chat'), + path('open', v2_views.OpenView.as_view(), name='open'), + path('text_to_speech', v2_views.TextToSpeech.as_view()), + path('speech_to_text', v2_views.SpeechToText.as_view()), + path('captcha', v2_views.CaptchaView.as_view(), name='captcha'), + path('/chat/completions', v2_views.OpenAIView.as_view(), name='application/chat_completions'), + path('vote/chat//chat_record/', v2_views.VoteView.as_view(), name='vote'), + path('historical_conversation', v2_views.HistoricalConversationView.as_view(), name='historical_conversation'), + path('historical_conversation//record/',v2_views.ChatRecordView.as_view(),name='conversation_details'), + path('historical_conversation//', v2_views.HistoricalConversationView.PageView.as_view(), name='historical_conversation'), + path('historical_conversation/clear',v2_views.HistoricalConversationView.BatchDelete.as_view(), name='historical_conversation_clear'), + path('historical_conversation/',v2_views.HistoricalConversationView.Operate.as_view(), name='historical_conversation_operate'), + path('historical_conversation_record/', v2_views.HistoricalConversationRecordView.as_view(), name='historical_conversation_record'), + path('historical_conversation_record///', v2_views.HistoricalConversationRecordView.PageView.as_view(), name='historical_conversation_record'), path('share/', ChatRecordDetailView.as_view()), path('/chat//share_chat', ChatRecordLinkView.as_view()), - path("chat_user/current/reset_password", views.ResetCurrentUserPasswordView.as_view(), name="reset_password_current"), - path('auth/login/', views.LocalLoginView.as_view()), - path('auth/logout', views.Logout.as_view(), name='logout'), - path('chat_user/profile', views.ChatUserProfileView.as_view(), name="chat_user_profile"), - path('api_key', views.ChatUserApiKeyView.as_view()), - path('api_key//', views.ChatUserApiKeyView.Page.as_view()), - path('api_key/', views.ChatUserApiKeyView.Operate.as_view()) + +] +urlpatterns = [ + *v2, + path('v3/',include(v3)) ] diff --git a/apps/chat/views/__init__.py b/apps/chat/views/__init__.py index a1f026a26b6..da273c43822 100644 --- a/apps/chat/views/__init__.py +++ b/apps/chat/views/__init__.py @@ -6,7 +6,5 @@ @date:2025/5/29 16:08 @desc: """ -from .chat_embed import * -from .chat import * -from .chat_record import * -from .chat_user_api_key import * +from . import v2 +from . import v3 diff --git a/apps/chat/views/v2/__init__.py b/apps/chat/views/v2/__init__.py new file mode 100644 index 00000000000..4cedec9c1d3 --- /dev/null +++ b/apps/chat/views/v2/__init__.py @@ -0,0 +1,12 @@ +# coding=utf-8 +""" + @project: MaxKB + @Author:虎虎 + @file: __init__.py.py + @date:2025/5/29 16:08 + @desc: +""" +from .chat_embed import * +from .chat import * +from .chat_record import * +from .mcp import mcp_view diff --git a/apps/chat/views/chat.py b/apps/chat/views/v2/chat.py similarity index 93% rename from apps/chat/views/chat.py rename to apps/chat/views/v2/chat.py index 0ae2081d49a..52355e46433 100644 --- a/apps/chat/views/chat.py +++ b/apps/chat/views/v2/chat.py @@ -25,7 +25,7 @@ from chat.api.chat_authentication_api import ChatAuthenticationAPI, ChatAuthenticationProfileAPI, ChatOpenAPI, OpenAIAPI from chat.serializers.chat import OpenChatSerializers, ChatSerializers, SpeechToTextSerializers, \ TextToSpeechSerializers, OpenAIChatSerializer -from chat.serializers.chat_authentication import AnonymousAuthenticationSerializer, ApplicationProfileSerializer, \ +from chat.serializers.chat_authentication import AnonymousAuthenticationV2Serializer, ApplicationProfileSerializer, \ AuthProfileSerializer from common.auth import ChatTokenAuth from common.auth.authentication import has_permissions @@ -97,11 +97,11 @@ class OpenAIView(APIView): ) def post(self, request: Request, application_id: str): ip_address = _get_ip_address(request) - if application_id != str(request.auth.application_id): + if application_id != str(request.user.kwargs.get('application_id')): raise AppAuthenticationFailed(500, _('Secret key is invalid')) return OpenAIChatSerializer( - data={'application_id': application_id, 'chat_user_id': request.auth.chat_user_id, - 'chat_user_type': request.auth.chat_user_type, + data={'application_id': application_id, 'chat_user_id': request.user.id, + 'chat_user_type': request.user.type, 'ip_address': ip_address, 'source': {"type": ChatSourceChoices.API_CALL.value}}).chat(request.data) @@ -118,12 +118,12 @@ def options(self, request, *args, **kwargs): description=_('Application Anonymous Certification'), summary=_('Application Anonymous Certification'), operation_id=_('Application Anonymous Certification'), # type: ignore - request=ChatAuthenticationAPI.get_request(), + request=AnonymousAuthenticationV2Serializer, responses=None, tags=[_('Chat')] # type: ignore ) def post(self, request: Request): - token, f_token = AnonymousAuthenticationSerializer().auth( + token, f_token = AnonymousAuthenticationV2Serializer(data=request.data).auth( request) response = result.success( token, @@ -157,10 +157,9 @@ class ApplicationProfile(APIView): responses=None, tags=[_('Chat')] # type: ignore ) - @has_permissions(ChatPermissionConstants.get_aggregate_permissions()) - def get(self, request: Request, application_id: str): + def get(self, request: Request): return result.success(ApplicationProfileSerializer( - data={'application_id': application_id}).profile()) + data={'application_id': request.user.kwargs.get('application_id')}).profile()) class AuthProfile(APIView): @@ -194,13 +193,13 @@ class ChatView(APIView): def post(self, request: Request, chat_id: str): ip_address = _get_ip_address(request) return ChatSerializers(data={'chat_id': chat_id, - 'chat_user_id': request.auth.chat_user_id, - 'chat_user_type': request.auth.chat_user_type, - 'application_id': request.auth.application_id, + 'chat_user_id': request.user.id, + 'chat_user_type': request.user.type, + 'application_id': request.user.kwargs.get('application_id'), 'debug': False, 'ip_address': ip_address, 'source': { - 'type': ChatSourceChoices.API_CALL.value if request.auth.chat_user_type == ChatUserType.APPLICATION_API_KEY.value else ChatSourceChoices.ONLINE.value} + 'type': ChatSourceChoices.API_CALL.value if request.user.type == ChatUserType.APPLICATION_API_KEY.value else ChatSourceChoices.ONLINE.value} } ).chat(request.data) @@ -218,10 +217,10 @@ class OpenView(APIView): tags=[_('Chat')] # type: ignore ) @has_permissions(ChatPermissionConstants.get_aggregate_permissions()) - def get(self, request: Request, application_id: str): + def get(self, request: Request): ip_address = _get_ip_address(request) return result.success(OpenChatSerializers( - data={'application_id': application_id, + data={'application_id': request.user.kwargs.get('application_id'), 'chat_user_id': request.user.id, 'chat_user_type': request.user.type, 'ip_address': ip_address, 'source': { @@ -283,7 +282,7 @@ class SpeechToText(APIView): def post(self, request: Request): return result.success( SpeechToTextSerializers( - data={'application_id': request.auth.application_id}) + data={'application_id': request.user.kwargs.get('application_id')}) .speech_to_text({'file': request.FILES.get('file')})) @@ -301,7 +300,7 @@ class TextToSpeech(APIView): ) def post(self, request: Request): byte_data = TextToSpeechSerializers( - data={'application_id': request.auth.application_id}).text_to_speech(request.data) + data={'application_id': request.user.kwargs.get('application_id')}).text_to_speech(request.data) return HttpResponse(byte_data, status=200, headers={'Content-Type': 'audio/mp3', 'Content-Disposition': 'attachment; filename="abc.mp3"'}) @@ -326,7 +325,7 @@ def post(self, request: Request, chat_id: str): for file in files: file_url = FileSerializer( data={'file': file, 'meta': meta, 'source_id': chat_id, 'source_type': FileSourceType.CHAT, }).upload( - request.auth.chat_user_id) + request.user.id) file_ids.append({'name': file.name, 'url': file_url, 'file_id': file_url.split('/')[-1]}) return result.success(file_ids) diff --git a/apps/chat/views/chat_embed.py b/apps/chat/views/v2/chat_embed.py similarity index 100% rename from apps/chat/views/chat_embed.py rename to apps/chat/views/v2/chat_embed.py diff --git a/apps/chat/views/chat_record.py b/apps/chat/views/v2/chat_record.py similarity index 87% rename from apps/chat/views/chat_record.py rename to apps/chat/views/v2/chat_record.py index 0731f3dfc02..db67eced891 100644 --- a/apps/chat/views/chat_record.py +++ b/apps/chat/views/v2/chat_record.py @@ -36,7 +36,7 @@ class VoteView(APIView): ) def put(self, request: Request, chat_id: str, chat_record_id: str): return result.success(VoteSerializer( - data={'application_id': request.auth.application_id, + data={'application_id': request.user.kwargs.get('application_id'), 'chat_id': chat_id, 'chat_record_id': chat_record_id }).vote(request.data)) @@ -57,8 +57,8 @@ class HistoricalConversationView(APIView): def get(self, request: Request): return result.success(HistoricalConversationSerializer( data={ - 'application_id': request.auth.application_id, - 'chat_user_id': request.auth.chat_user_id, + 'application_id': request.user.kwargs.get('application_id'), + 'chat_user_id': request.user.id, }).list()) class Operate(APIView): @@ -77,8 +77,8 @@ class Operate(APIView): def put(self, request: Request, chat_id: str): return result.success(HistoricalConversationOperateSerializer( data={ - 'application_id': request.auth.application_id, - 'chat_user_id': request.auth.chat_user_id, + 'application_id': request.user.kwargs.get('application_id'), + 'chat_user_id': request.user.id, 'chat_id': chat_id, }).edit_abstract(request.data) ) @@ -95,8 +95,8 @@ def put(self, request: Request, chat_id: str): def delete(self, request: Request, chat_id: str): return result.success(HistoricalConversationOperateSerializer( data={ - 'application_id': request.auth.application_id, - 'chat_user_id': request.auth.chat_user_id, + 'application_id': request.user.kwargs.get('application_id'), + 'chat_user_id': request.user.id, 'chat_id': chat_id, }).logic_delete()) @@ -114,8 +114,8 @@ class BatchDelete(APIView): ) def delete(self, request: Request): return result.success(HistoricalConversationOperateSerializer.Clear(data={ - 'application_id': request.auth.application_id, - 'chat_user_id': request.auth.chat_user_id, + 'application_id': request.user.kwargs.get('application_id'), + 'chat_user_id': request.user.id, }).batch_logic_delete()) class PageView(APIView): @@ -133,8 +133,8 @@ class PageView(APIView): def get(self, request: Request, current_page: int, page_size: int): return result.success(HistoricalConversationSerializer( data={ - 'application_id': request.auth.application_id, - 'chat_user_id': request.auth.chat_user_id, + 'application_id': request.user.kwargs.get('application_id'), + 'chat_user_id': request.user.id, }).page(current_page, page_size)) @@ -154,8 +154,8 @@ def get(self, request: Request, chat_id: str): return result.success(HistoricalConversationRecordSerializer( data={ 'chat_id': chat_id, - 'application_id': request.auth.application_id, - 'chat_user_id': request.auth.chat_user_id, + 'application_id': request.user.kwargs.get('application_id'), + 'chat_user_id': request.user.id, }).list()) class PageView(APIView): @@ -174,8 +174,8 @@ def get(self, request: Request, chat_id: str, current_page: int, page_size: int) return result.success(HistoricalConversationRecordSerializer( data={ 'chat_id': chat_id, - 'application_id': request.auth.application_id, - 'chat_user_id': request.auth.chat_user_id, + 'application_id': request.user.kwargs.get('application_id'), + 'chat_user_id': request.user.id, }).page(current_page, page_size)) @@ -196,6 +196,6 @@ def get(self, request: Request, chat_id: str, chat_record_id: str): data={ 'chat_id': chat_id, 'chat_record_id': chat_record_id, - 'application_id': request.auth.application_id, - 'chat_user_id': request.auth.chat_user_id, + 'application_id': request.user.kwargs.get('application_id'), + 'chat_user_id': request.user.id, }).one(False)) diff --git a/apps/chat/views/mcp.py b/apps/chat/views/v2/mcp.py similarity index 100% rename from apps/chat/views/mcp.py rename to apps/chat/views/v2/mcp.py diff --git a/apps/chat/views/v3/__init__.py b/apps/chat/views/v3/__init__.py new file mode 100644 index 00000000000..83ed2012d6f --- /dev/null +++ b/apps/chat/views/v3/__init__.py @@ -0,0 +1,13 @@ +# coding=utf-8 +""" + @project: MaxKB + @Author:虎虎 + @file: __init__.py + @date:2025/6/6 11:18 + @desc: +""" +from .chat_embed import * +from .chat import * +from .chat_record import * +from .chat_user_api_key import * +from .mcp import mcp_view diff --git a/apps/chat/views/v3/chat.py b/apps/chat/views/v3/chat.py new file mode 100644 index 00000000000..0a3cf694c70 --- /dev/null +++ b/apps/chat/views/v3/chat.py @@ -0,0 +1,447 @@ +# coding=utf-8 +""" + @project: MaxKB + @Author:虎虎 + @file: chat.py + @date:2025/6/6 11:18 + @desc: +""" +import json + +import requests +from django.core.cache import cache +from django.http import HttpResponse, StreamingHttpResponse +from django.utils.translation import gettext_lazy as _ +from drf_spectacular.utils import extend_schema +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter +from rest_framework.parsers import MultiPartParser +from rest_framework.request import Request +from rest_framework.views import APIView + +from application.api.application_api import SpeechToTextAPI, TextToSpeechAPI +from application.models import ChatUserType, ChatSourceChoices +from chat.api.chat_api import ChatAPI +from chat.api.chat_authentication_api import ChatAuthenticationAPI, ChatAuthenticationProfileAPI, ChatOpenAPI, OpenAIAPI +from chat.serializers.chat import OpenChatSerializers, ChatSerializers, SpeechToTextSerializers, \ + TextToSpeechSerializers, OpenAIChatSerializer +from chat.serializers.chat_authentication import AnonymousAuthenticationSerializer, ApplicationProfileSerializer, \ + AuthProfileSerializer +from common.auth import ChatTokenAuth +from common.auth.authentication import has_permissions +from common.auth.common import FileToken +from common.auth.constants.chat_permission_constants import ChatPermissionConstants +from common.constants.authentication_type import AuthenticationType +from common.constants.cache_version import Cache_Version +from common.auth.common import ChatAuthentication +from common.exception.app_exception import AppAuthenticationFailed, AppApiException +from common.log.log import _get_ip_address, log +from common.result import result +from common.utils.rsa_util import decrypt +from knowledge.models import FileSourceType +from maxkb.const import CONFIG +from models_provider.api.model import DefaultModelResponse +from oss.serializers.file import FileSerializer +from system_manage.serializers.chat_user import RePasswordSerializer, ChatUserProfileSerializer +from system_manage.serializers.chat_user_serializer import ChatUserAccessTokenSerializer +from users.api import CaptchaAPI, LoginAPI +from users.api.user import ResetPasswordAPI, UserProfileAPI +from users.serializers.login import CaptchaSerializer +from users.views import get_re_password_details + + +def stream_image(response): + """生成器函数,用于流式传输图片数据""" + for chunk in response.iter_content(chunk_size=4096): + if chunk: # 过滤掉保持连接的空块 + yield chunk + + +class ResourceProxy(APIView): + def get(self, request: Request): + image_url = request.query_params.get("url") + if not image_url: + return result.error("Missing 'url' parameter") + try: + + # 发送GET请求,流式获取图片内容 + response = requests.get( + image_url, + stream=True, # 启用流式响应 + allow_redirects=True, + timeout=10 + ) + content_type = response.headers.get('Content-Type', '').split(';')[0] + # 创建Django流式响应 + django_response = StreamingHttpResponse( + stream_image(response), # 使用生成器 + content_type=content_type + ) + + return django_response + except Exception as e: + return result.error(f"Image request failed: {str(e)}") + + +class OpenAIView(APIView): + authentication_classes = [ChatTokenAuth] + + @extend_schema( + methods=['POST'], + description=_('OpenAI Interface Dialogue'), + summary=_('OpenAI Interface Dialogue'), + operation_id=_('V3 OpenAI Interface Dialogue'), # type: ignore + request=OpenAIAPI.get_request(), + responses=None, + tags=[_('V3 Chat')] # type: ignore + ) + def post(self, request: Request, application_id: str): + ip_address = _get_ip_address(request) + return OpenAIChatSerializer( + data={'application_id': application_id, 'chat_user_id': request.user.id, + 'chat_user_type': request.user.type, + 'ip_address': ip_address, + 'source': {"type": ChatSourceChoices.API_CALL.value}}).chat(request.data) + + +class AnonymousAuthentication(APIView): + def options(self, request, *args, **kwargs): + return HttpResponse( + headers={"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "POST", + "Access-Control-Allow-Headers": "Origin,Content-Type,Cookie,Accept,Token"}, ) + + @extend_schema( + methods=['POST'], + description=_('Application Anonymous Certification'), + summary=_('Application Anonymous Certification'), + operation_id=_('V3 Application Anonymous Certification'), # type: ignore + request=ChatAuthenticationAPI.get_request(), + responses=None, + tags=[_('V3 Chat')] # type: ignore + ) + def post(self, request: Request): + token, f_token = AnonymousAuthenticationSerializer().auth( + request) + response = result.success( + token, + headers={"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "POST", + "Access-Control-Allow-Headers": "Origin,Content-Type,Cookie,Accept,Token"} + ) + is_https = request.scheme == "https" + + response.set_cookie( + key="mk_file_auth", + value=f_token, + max_age=7 * 24 * 3600, + path=f'{CONFIG.get_chat_path()}/{request.data.get("access_token")}', + secure=is_https, + httponly=True, + samesite="None" if is_https else "Lax", + ) + return response + + +class ApplicationProfile(APIView): + authentication_classes = [ChatTokenAuth] + + @extend_schema( + methods=['GET'], + description=_("Get application related information"), + summary=_("Get application related information"), + operation_id=_("V3 Get application related information"), # type: ignore + request=None, + responses=None, + tags=[_('V3 Chat')] # type: ignore + ) + @has_permissions(ChatPermissionConstants.get_aggregate_permissions()) + def get(self, request: Request, application_id: str): + return result.success(ApplicationProfileSerializer( + data={'application_id': application_id}).profile()) + + +class AuthProfile(APIView): + @extend_schema( + methods=['GET'], + description=_("Get application authentication information"), + summary=_("Get application authentication information"), + operation_id=_("V3 Get application authentication information"), # type: ignore + parameters=ChatAuthenticationProfileAPI.get_parameters(), + responses=None, + tags=[_('V3 Chat')] # type: ignore + ) + def get(self, request: Request): + return result.success( + AuthProfileSerializer(data={'access_token': request.query_params.get("access_token")}).profile()) + + +class ChatView(APIView): + authentication_classes = [ChatTokenAuth] + + @extend_schema( + methods=['POST'], + description=_("dialogue"), + summary=_("dialogue"), + operation_id=_("V3 dialogue"), # type: ignore + request=ChatAPI.get_request(), + parameters=ChatAPI.get_parameters(), + responses=None, + tags=[_('V3 Chat')] # type: ignore + ) + @has_permissions(ChatPermissionConstants.get_aggregate_permissions()) + def post(self, request: Request, application_id: str, chat_id: str): + ip_address = _get_ip_address(request) + return ChatSerializers(data={'chat_id': chat_id, + 'chat_user_id': request.user.id, + 'chat_user_type': request.user.type, + 'application_id': application_id, + 'debug': False, + 'ip_address': ip_address, + 'source': { + 'type': ChatSourceChoices.API_CALL.value if request.user.type == ChatUserType.APPLICATION_API_KEY.value else ChatSourceChoices.ONLINE.value} + } + ).chat(request.data) + + +class OpenView(APIView): + authentication_classes = [ChatTokenAuth] + + @extend_schema( + methods=['GET'], + description=_("Get the session id according to the application id"), + summary=_("Get the session id according to the application id"), + operation_id=_("V3 Get the session id according to the application id"), # type: ignore + parameters=ChatOpenAPI.get_parameters(), + responses=None, + tags=[_('V3 Chat')] # type: ignore + ) + @has_permissions(ChatPermissionConstants.get_aggregate_permissions()) + def get(self, request: Request, application_id: str): + ip_address = _get_ip_address(request) + return result.success(OpenChatSerializers( + data={'application_id': application_id, + 'chat_user_id': request.user.id, 'chat_user_type': request.user.type, + 'ip_address': ip_address, + 'source': { + 'type': ChatSourceChoices.API_CALL.value if request.user.type == ChatUserType.APPLICATION_API_KEY.value else ChatSourceChoices.ONLINE.value}, + 'debug': False}).open()) + + +class CancelWorkflowView(APIView): + authentication_classes = [ChatTokenAuth] + + @extend_schema( + methods=['POST'], + description=_("Cancel running workflow"), + summary=_("Cancel running workflow"), + operation_id=_("V3 Cancel running workflow"), # type: ignore + parameters=[ + OpenApiParameter(name='chat_id', type=OpenApiTypes.UUID, location=OpenApiParameter.PATH, + description=_('Chat ID')), + ], + responses=None, + tags=[_('V3 Chat')] # type: ignore + ) + def post(self, request: Request, chat_id: str): + from application.workflow.workflow_run_registry import WorkflowRunRegistry, CancelResult + result_enum = WorkflowRunRegistry.cancel_by_chat_id(chat_id) + if result_enum == CancelResult.CANCELLED: + return result.success({'status': 'cancelled', 'chat_id': chat_id}) + elif result_enum == CancelResult.NOT_FOUND: + return result.success({'status': 'not_found', 'chat_id': chat_id}) + else: + return result.fail(500, _('Failed to cancel workflow')) + + +class CaptchaView(APIView): + @extend_schema(methods=['GET'], + summary=_("Get Chat captcha"), + description=_("Get Chat captcha"), + operation_id=_("V3 Get Chat captcha"), # type: ignore + tags=[_("V3 Chat")], # type: ignore + responses=CaptchaAPI.get_response()) + def get(self, request: Request): + username = request.query_params.get('username', None) + accessToken = request.query_params.get('accessToken', None) + return result.success(CaptchaSerializer().chat_generate(username, 'chat', accessToken)) + + +class SpeechToText(APIView): + authentication_classes = [ChatTokenAuth] + + @extend_schema( + methods=['POST'], + description=_("speech to text"), + summary=_("speech to text"), + operation_id=_("V3 speech to text"), # type: ignore + request=SpeechToTextAPI.get_request(), + responses=SpeechToTextAPI.get_response(), + tags=[_('V3 Chat')] # type: ignore + ) + @has_permissions(ChatPermissionConstants.get_aggregate_permissions()) + def post(self, request: Request, application_id: str): + return result.success( + SpeechToTextSerializers( + data={'application_id': application_id}) + .speech_to_text({'file': request.FILES.get('file')})) + + +class TextToSpeech(APIView): + authentication_classes = [ChatTokenAuth] + + @extend_schema( + methods=['POST'], + description=_("text to speech"), + summary=_("text to speech"), + operation_id=_("V3 text to speech"), # type: ignore + request=TextToSpeechAPI.get_request(), + responses=TextToSpeechAPI.get_response(), + tags=[_('V3 Chat')] # type: ignore + ) + @has_permissions(ChatPermissionConstants.get_aggregate_permissions()) + def post(self, request: Request, application_id: str): + byte_data = TextToSpeechSerializers( + data={'application_id': application_id}).text_to_speech(request.data) + return HttpResponse(byte_data, status=200, headers={'Content-Type': 'audio/mp3', + 'Content-Disposition': 'attachment; filename="abc.mp3"'}) + + +class UploadFile(APIView): + authentication_classes = [ChatTokenAuth] + parser_classes = [MultiPartParser] + + @extend_schema( + methods=['POST'], + description=_("Upload files"), + summary=_("Upload files"), + operation_id=_("V3 Upload files"), # type: ignore + request=TextToSpeechAPI.get_request(), + responses=TextToSpeechAPI.get_response(), + tags=[_('V3 Application')] # type: ignore + ) + def post(self, request: Request, chat_id: str): + files = request.FILES.getlist('file') + file_ids = [] + meta = {} + for file in files: + file_url = FileSerializer( + data={'file': file, 'meta': meta, 'source_id': chat_id, 'source_type': FileSourceType.CHAT, }).upload( + request.user.id) + file_ids.append({'name': file.name, 'url': file_url, 'file_id': file_url.split('/')[-1]}) + return result.success(file_ids) + + +class ResetCurrentUserPasswordView(APIView): + authentication_classes = [ChatTokenAuth] + + @extend_schema( + methods=["POST"], + summary=_("Modify current user password"), + description=_("Modify current user password"), + operation_id=_("V3 Modify current user password"), # type: ignore + tags=[_("V3 Chat User")], # type: ignore + request=ResetPasswordAPI.get_request(), + responses=DefaultModelResponse.get_response(), + ) + @log( + menu="Chat User", + operate="Modify current user password", + get_operation_object=lambda r, k: {"name": r.user.username}, + get_details=get_re_password_details, + ) + def post(self, request: Request): + request_data = request.data + encrypted_data = request_data.get("encryptedData", "") + if encrypted_data: + try: + decrypted_raw = decrypt(encrypted_data) + # decrypt 可能返回非 JSON 字符串,防护解析异常 + decrypted_data = json.loads(decrypted_raw) if decrypted_raw else {} + if isinstance(decrypted_data, dict): + request_data = decrypted_data + except Exception as e: + raise AppApiException(500, _("Invalid encrypted data")) + serializer_obj = RePasswordSerializer(data=request_data) + if serializer_obj.reset_password(request.user.id): + version, get_key = Cache_Version.CHAT_USER_TOKEN.value + auth = request.META.get("HTTP_AUTHORIZATION") + cache.delete(get_key(token=auth), version=version) + return result.success(True) + return result.error(_("Failed to change password")) + + +class ChatUserProfileView(APIView): + authentication_classes = [ChatTokenAuth] + + @extend_schema( + methods=["GET"], + summary=_("Get current user information"), + description=_("Get current user information"), + operation_id=_("V3 Get current user information"), # type: ignore + tags=[_("V3 Chat User")], # type: ignore + responses=UserProfileAPI.get_response(), + ) + def get(self, request: Request): + return result.success(ChatUserProfileSerializer().profile(request.user)) + + +class BaseAuthView(APIView): + @staticmethod + def create_token_and_cache(access_token, user, request): + token = ChatUserAccessTokenSerializer.create_token_and_cache(access_token, user, request) + version, get_key = Cache_Version.CHAT_USER_TOKEN.value + cache.set(get_key(token), user, timeout=60 * 60 * 2, version=version) + return token, FileToken(str(user.id), AuthenticationType.CHAT_USER.value).to_token() + + @classmethod + def generate(self, request, f_token: str, response: HttpResponse, path: str = '/chat'): + secure = request.is_secure() + response.set_cookie( + "mk_file_auth", + value=f_token, + max_age=7 * 24 * 3600, + path=path, + domain=None, + secure=secure, + httponly=True, + samesite="Lax", + ) + return response + + +class LocalLoginView(BaseAuthView): + @extend_schema( + methods=["POST"], + description=_("Log in"), + summary=_("Log in"), + operation_id=_("V3 Log in"), # type: ignore + tags=[_("V3 Chat User/login")], # type: ignore + request=LoginAPI.get_request(), + responses=LoginAPI.get_response(), + ) + def post(self, request: Request, access_token: str = None): + user = ChatUserAccessTokenSerializer.local_login(request.data, access_token) + user.source = "LOCAL" + token, f_token = self.create_token_and_cache(access_token, user, request) + response = result.success({'token': token}) + return self.generate(request, f_token, response, path=f'/chat/{access_token}/') + + +class Logout(APIView): + authentication_classes = [ChatTokenAuth] + + @extend_schema( + methods=["POST"], + summary=_("Sign out"), + description=_("Sign out"), + operation_id=_("V3 Sign out"), # type: ignore + tags=[_("V3 Chat User")], # type: ignore + responses=DefaultModelResponse.get_response(), + ) + @log(menu="Chat User/logout", operate="Sign out", get_operation_object=lambda r, k: {"name": r.user.username}) + def post(self, request: Request): + version, get_key = Cache_Version.CHAT_USER_TOKEN.value + auth = request.META.get("HTTP_AUTHORIZATION") + cache.delete(get_key(token=auth[7:]), version=version) + return result.success(True) diff --git a/apps/chat/views/v3/chat_embed.py b/apps/chat/views/v3/chat_embed.py new file mode 100644 index 00000000000..4e5310214b0 --- /dev/null +++ b/apps/chat/views/v3/chat_embed.py @@ -0,0 +1,32 @@ +# coding=utf-8 +""" + @project: MaxKB + @Author:虎虎 + @file: chat_embed.py + @date:2025/5/30 15:22 + @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 chat.api.chat_embed_api import ChatEmbedAPI +from chat.serializers.chat_embed_serializers import ChatEmbedSerializer + + +class ChatEmbedView(APIView): + + @extend_schema( + methods=['GET'], + description=_('Get embedded js'), + summary=_('Get embedded js'), + operation_id=_('V3 Get embedded js'), # type: ignore + parameters=ChatEmbedAPI.get_parameters(), + responses=ChatEmbedAPI.get_response(), + tags=[_('V3 Chat')] # type: ignore + ) + def get(self, request: Request): + return ChatEmbedSerializer( + data={'protocol': request.query_params.get('protocol'), 'token': request.query_params.get('token'), + 'host': request.query_params.get('host'), }).get_embed(params=request.query_params) diff --git a/apps/chat/views/v3/chat_record.py b/apps/chat/views/v3/chat_record.py new file mode 100644 index 00000000000..15c0e1794c0 --- /dev/null +++ b/apps/chat/views/v3/chat_record.py @@ -0,0 +1,212 @@ +# coding=utf-8 +""" + @project: MaxKB + @Author:虎虎 + @file: chat_record.py + @date:2025/6/23 10:42 + @desc: v3 chat record views —— application_id 从 path 获取,用户身份从 request.user(Principal) 获取 +""" +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.serializers.application_chat_record import ChatRecordOperateSerializer +from chat.api.chat_api import HistoricalConversationAPI, PageHistoricalConversationAPI, \ + PageHistoricalConversationRecordAPI, HistoricalConversationRecordAPI, HistoricalConversationOperateAPI +from chat.api.vote_api import VoteAPI +from chat.serializers.chat_record import VoteSerializer, HistoricalConversationSerializer, \ + HistoricalConversationRecordSerializer, HistoricalConversationOperateSerializer +from common import result +from common.auth import ChatTokenAuth +from common.auth.authentication import has_permissions +from common.auth.constants.chat_permission_constants import ChatPermissionConstants + + +class VoteView(APIView): + authentication_classes = [ChatTokenAuth] + + @extend_schema( + methods=['PUT'], + description=_("Like, Dislike"), + summary=_("Like, Dislike"), + operation_id=_("V3 Like, Dislike"), # type: ignore + parameters=VoteAPI.get_parameters(), + request=VoteAPI.get_request(), + responses=VoteAPI.get_response(), + tags=[_('V3 Chat')] # type: ignore + ) + @has_permissions(ChatPermissionConstants.get_aggregate_permissions()) + def put(self, request: Request, application_id: str, chat_id: str, chat_record_id: str): + return result.success(VoteSerializer( + data={'application_id': application_id, + 'chat_id': chat_id, + 'chat_record_id': chat_record_id + }).vote(request.data)) + + +class HistoricalConversationView(APIView): + authentication_classes = [ChatTokenAuth] + + @extend_schema( + methods=['GET'], + description=_("Get historical conversation"), + summary=_("Get historical conversation"), + operation_id=_("V3 Get historical conversation"), # type: ignore + parameters=HistoricalConversationAPI.get_parameters(), + responses=HistoricalConversationAPI.get_response(), + tags=[_('V3 Chat')] # type: ignore + ) + @has_permissions(ChatPermissionConstants.get_aggregate_permissions()) + def get(self, request: Request, application_id: str): + return result.success(HistoricalConversationSerializer( + data={ + 'application_id': application_id, + 'chat_user_id': request.user.id, + }).list()) + + class Operate(APIView): + authentication_classes = [ChatTokenAuth] + + @extend_schema( + methods=['PUT'], + description=_("Modify conversation about"), + summary=_("Modify conversation about"), + operation_id=_("V3 Modify conversation about"), # type: ignore + parameters=HistoricalConversationOperateAPI.get_parameters(), + request=HistoricalConversationOperateAPI.get_request(), + responses=HistoricalConversationOperateAPI.get_response(), + tags=[_('V3 Chat')] # type: ignore + ) + @has_permissions(ChatPermissionConstants.get_aggregate_permissions()) + def put(self, request: Request, application_id: str, chat_id: str): + return result.success(HistoricalConversationOperateSerializer( + data={ + 'application_id': application_id, + 'chat_user_id': request.user.id, + 'chat_id': chat_id, + }).edit_abstract(request.data) + ) + + @extend_schema( + methods=['DELETE'], + description=_("Delete history conversation"), + summary=_("Delete history conversation"), + operation_id=_("V3 Delete history conversation"), # type: ignore + parameters=HistoricalConversationOperateAPI.get_parameters(), + responses=HistoricalConversationOperateAPI.get_response(), + tags=[_('V3 Chat')] # type: ignore + ) + @has_permissions(ChatPermissionConstants.get_aggregate_permissions()) + def delete(self, request: Request, application_id: str, chat_id: str): + return result.success(HistoricalConversationOperateSerializer( + data={ + 'application_id': application_id, + 'chat_user_id': request.user.id, + 'chat_id': chat_id, + }).logic_delete()) + + class BatchDelete(APIView): + authentication_classes = [ChatTokenAuth] + + @extend_schema( + methods=['DELETE'], + description=_("Batch delete history conversation"), + summary=_("Batch delete history conversation"), + operation_id=_("V3 Batch delete history conversation"), # type: ignore + parameters=HistoricalConversationOperateAPI.get_parameters(), + responses=HistoricalConversationOperateAPI.get_response(), + tags=[_('V3 Chat')] # type: ignore + ) + @has_permissions(ChatPermissionConstants.get_aggregate_permissions()) + def delete(self, request: Request, application_id: str): + return result.success(HistoricalConversationOperateSerializer.Clear(data={ + 'application_id': application_id, + 'chat_user_id': request.user.id, + }).batch_logic_delete()) + + class PageView(APIView): + authentication_classes = [ChatTokenAuth] + + @extend_schema( + methods=['GET'], + description=_("Get historical conversation by page"), + summary=_("Get historical conversation by page"), + operation_id=_("V3 Get historical conversation by page"), # type: ignore + parameters=PageHistoricalConversationAPI.get_parameters(), + responses=PageHistoricalConversationAPI.get_response(), + tags=[_('V3 Chat')] # type: ignore + ) + @has_permissions(ChatPermissionConstants.get_aggregate_permissions()) + def get(self, request: Request, application_id: str, current_page: int, page_size: int): + return result.success(HistoricalConversationSerializer( + data={ + 'application_id': application_id, + 'chat_user_id': request.user.id, + }).page(current_page, page_size)) + + +class HistoricalConversationRecordView(APIView): + authentication_classes = [ChatTokenAuth] + + @extend_schema( + methods=['GET'], + description=_("Get historical conversation records"), + summary=_("Get historical conversation records"), + operation_id=_("V3 Get historical conversation records"), # type: ignore + parameters=HistoricalConversationRecordAPI.get_parameters(), + responses=HistoricalConversationRecordAPI.get_response(), + tags=[_('V3 Chat')] # type: ignore + ) + @has_permissions(ChatPermissionConstants.get_aggregate_permissions()) + def get(self, request: Request, application_id: str, chat_id: str): + return result.success(HistoricalConversationRecordSerializer( + data={ + 'chat_id': chat_id, + 'application_id': application_id, + 'chat_user_id': request.user.id, + }).list()) + + class PageView(APIView): + authentication_classes = [ChatTokenAuth] + + @extend_schema( + methods=['GET'], + description=_("Get historical conversation records by page "), + summary=_("Get historical conversation records by page"), + operation_id=_("V3 Get historical conversation records by page"), # type: ignore + parameters=PageHistoricalConversationRecordAPI.get_parameters(), + responses=PageHistoricalConversationRecordAPI.get_response(), + tags=[_('V3 Chat')] # type: ignore + ) + @has_permissions(ChatPermissionConstants.get_aggregate_permissions()) + def get(self, request: Request, application_id: str, chat_id: str, current_page: int, page_size: int): + return result.success(HistoricalConversationRecordSerializer( + data={ + 'chat_id': chat_id, + 'application_id': application_id, + 'chat_user_id': request.user.id, + }).page(current_page, page_size)) + + +class ChatRecordView(APIView): + authentication_classes = [ChatTokenAuth] + + @extend_schema( + methods=['GET'], + description=_("Get conversation details"), + summary=_("Get conversation details"), + operation_id=_("V3 Get conversation details"), # type: ignore + parameters=PageHistoricalConversationRecordAPI.get_parameters(), + responses=PageHistoricalConversationRecordAPI.get_response(), + tags=[_('V3 Chat')] # type: ignore + ) + @has_permissions(ChatPermissionConstants.get_aggregate_permissions()) + def get(self, request: Request, application_id: str, chat_id: str, chat_record_id: str): + return result.success(ChatRecordOperateSerializer( + data={ + 'chat_id': chat_id, + 'chat_record_id': chat_record_id, + 'application_id': application_id, + 'chat_user_id': request.user.id, + }).one(False)) diff --git a/apps/chat/views/chat_user_api_key.py b/apps/chat/views/v3/chat_user_api_key.py similarity index 84% rename from apps/chat/views/chat_user_api_key.py rename to apps/chat/views/v3/chat_user_api_key.py index fab0387bb52..a8430e0fe28 100644 --- a/apps/chat/views/chat_user_api_key.py +++ b/apps/chat/views/v3/chat_user_api_key.py @@ -18,9 +18,9 @@ class ChatUserApiKeyView(APIView): methods=["POST"], description=_("Create ChatUserAPIKey"), summary=_("Create ChatUserAPIKey"), - operation_id=_("Create ChatUserAPIKey"), + operation_id="V3 Create ChatUserAPIKey", responses=None, - tags=[_("Chat User API Key")], + tags=[_("V3 Chat User API Key")], ) @log(menu="Chat User API Key", operate="Add chat user API key") def post(self, request: Request): @@ -33,18 +33,18 @@ class Page(APIView): methods=["GET"], description=_("Get ChatUserAPIKey List"), summary=_("Get ChatUserAPIKey List"), - operation_id=_("Get ChatUserAPIKey List"), + operation_id="V3 Get ChatUserAPIKey List", parameters=[ OpenApiParameter(name='order_by', type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, description=_('order by'), required=False), ], responses=None, - tags=[_("Chat User API Key")], + tags=[_("V3 Chat User API Key")], ) def get(self, request: Request, current_page, page_size): return result.success( ChatUserApiKeySerializer( - data={"user_id": request.user.id,"order_by": request.query_params.get("order_by")} + data={"user_id": request.user.id, "order_by": request.query_params.get("order_by")} ).page(current_page, page_size) ) @@ -55,10 +55,10 @@ class Operate(APIView): methods=["DELETE"], description=_("Delete ChatUserAPIKey"), summary=_("Delete ChatUserAPIKey"), - operation_id=_("Delete ChatUserAPIKey"), + operation_id="V3 Delete ChatUserAPIKey", responses=None, parameters=None, - tags=[_("Chat User API Key")], + tags=[_("V3 Chat User API Key")], ) @log(menu="Chat User API Key", operate="Delete chat user API key") def delete(self, request: Request, api_key_id: str): @@ -66,4 +66,4 @@ def delete(self, request: Request, api_key_id: str): ChatUserApiKeySerializer.Operate( data={"id": api_key_id, "user_id": request.user.id} ).destroy() - ) \ No newline at end of file + ) diff --git a/apps/chat/views/v3/mcp.py b/apps/chat/views/v3/mcp.py new file mode 100644 index 00000000000..a310250b754 --- /dev/null +++ b/apps/chat/views/v3/mcp.py @@ -0,0 +1,59 @@ +import json + +from django.http import JsonResponse, HttpResponse +from django.views.decorators.csrf import csrf_exempt + +from chat.mcp.tools import MCPToolHandler + + +@csrf_exempt +def mcp_view(request): + request_id = None + try: + data = json.loads(request.body) + method = data.get("method") + params = data.get("params", {}) + request_id = data.get("id") + + if request_id is None: + return HttpResponse(status=204) + + auth_header = request.headers.get("Authorization", "").replace("Bearer ", "") + handler = MCPToolHandler(auth_header) + + # 路由方法 + if method == "initialize": + result = handler.initialize() + + elif method == "tools/list": + result = handler.list_tools() + + elif method == "tools/call": + result = handler.call_tool(params) + + else: + return JsonResponse({ + "jsonrpc": "2.0", + "id": request_id, + "error": { + "code": -32601, + "message": f"Method not found: {method}" + } + }) + + # 成功响应 + return JsonResponse({ + "jsonrpc": "2.0", + "id": request_id, + "result": result + }) + + except Exception as e: + return JsonResponse({ + "jsonrpc": "2.0", + "id": request_id, + "error": { + "code": -32603, + "message": f"Internal error: {str(e)}" + } + }) diff --git a/apps/common/auth/common.py b/apps/common/auth/common.py index 6f5f23fbc66..8eeea6af29b 100644 --- a/apps/common/auth/common.py +++ b/apps/common/auth/common.py @@ -115,16 +115,18 @@ def new_instance(token_dict): class ChatToken: - def __init__(self, user_id, _type: AuthenticationType, login_type: str): + def __init__(self, user_id, _type: AuthenticationType, login_type: str, **kwargs): self.user_id = user_id self.type = _type self.login_type = login_type + self.kwargs = kwargs def to_dict(self): return { 'user_id': str(self.user_id), 'type': str(self.type.value), - 'login_type': str(self.login_type) + 'login_type': str(self.login_type), + 'kwargs': self.kwargs } def to_token(self): diff --git a/apps/common/auth/handle/impl/application_key.py b/apps/common/auth/handle/impl/application_key.py index bd5cbd074b4..48bc60d1681 100644 --- a/apps/common/auth/handle/impl/application_key.py +++ b/apps/common/auth/handle/impl/application_key.py @@ -32,7 +32,8 @@ def handle(self, request, token: str, get_token_details): if application_access_token.authentication_value.get('type', 'password') != 'password': raise AppAuthenticationFailed(1002, _('Authentication information is incorrect')) - return Principal(str(application_api_key.id), ChatUserType.APPLICATION_API_KEY), Auth(set(), {}) + return Principal(str(application_api_key.id), ChatUserType.APPLICATION_API_KEY, + application_id=str(application_api_key.application_id)), Auth(set(), {}) def support(self, request, token: str, get_token_details): return str(token).startswith("application-") or str(token).startswith('agent-') diff --git a/apps/common/auth/handle/impl/chat_user_token.py b/apps/common/auth/handle/impl/chat_user_token.py index c3d29e057a1..b3f9a6ebd98 100644 --- a/apps/common/auth/handle/impl/chat_user_token.py +++ b/apps/common/auth/handle/impl/chat_user_token.py @@ -9,6 +9,7 @@ from functools import reduce from django.db.models import QuerySet, Q +from django.utils.translation import gettext_lazy as _ from application.models import ApplicationAccessToken, ChatUserType from common.auth.constants.chat_permission_constants import ChatPermissionConstants, CHAT_PERMISSION_STR_MAP @@ -17,6 +18,7 @@ from common.auth.handle.auth_base_handle import AuthBaseHandle from common.auth.struct.auth import Principal, Auth from common.constants.authentication_type import AuthenticationType +from common.exception.app_exception import AppUnauthorizedFailed from system_manage.models import ResourceChatUserGroupAuthorize, ResourceType, ResourceChatUserAuthorize, \ UserGroupRelation @@ -39,6 +41,7 @@ def handle(self, request, token: str, get_token_details): ) _type = ChatUserType.ANONYMOUS_USER login_type = auth_details.get('login_type') + application_id = (auth_details.get('kwargs') or {}).get('application_id') if login_type.upper() == str(Operate.ANNOTATION_AUTH): application_access_token_list = application_access_token_list.filter(authentication=False) elif login_type.upper() == str(Operate.PASSWORD): @@ -68,6 +71,8 @@ def handle(self, request, token: str, get_token_details): Q(authentication_value__login_value__contains=login_type), Q(application_id__in=group_qs) | Q(application_id__in=user_qs), ) + if application_id: + application_access_token_list = application_access_token_list.filter(application_id=application_id) permissions = {} for application_access_token in application_access_token_list: permission_list = [] @@ -87,4 +92,11 @@ def handle(self, request, token: str, get_token_details): permission_list.append(ChatPermissionConstants.CHAT_USER_ANONYMOUS.value) k = f"{Group.CHAT_USER}:r:{application_access_token.application_id}" permissions[k] = reduce(lambda x, y: x | y, [p.bit() for p in permission_list], 0) + if application_id: + # 指定了 application_id(v2 流程)时,直接校验该应用是否有权限,无权限直接抛错, + # 避免返回一个空权限的 Principal 造成静默失败。 + if not permissions.get(f"{Group.CHAT_USER}:r:{application_id}"): + raise AppUnauthorizedFailed(403, _('No permission to access')) + return Principal(auth_details.get('user_id'), _type, application_id=application_id), Auth(set(), + permissions) return Principal(auth_details.get('user_id'), _type), Auth(set(), permissions) diff --git a/apps/common/auth/struct/auth.py b/apps/common/auth/struct/auth.py index 82e659312aa..2c87af82a19 100644 --- a/apps/common/auth/struct/auth.py +++ b/apps/common/auth/struct/auth.py @@ -22,20 +22,20 @@ class Auth: def __init__(self, roles: set[RoleConstants | Role | str], permissions: Dict[str, int], - **keywords): + **kwargs): # 权限列表 self.permissions = permissions # 角色列表 self.roles = roles - self.keywords = keywords + self.kwargs = kwargs class Principal: def __init__(self, _id, _type: ChatUserType | UserType, profile=None, - **keywords): + **kwargs): self.id = _id self.type = _type self.profile = profile - self.keywords = keywords + self.kwargs = kwargs diff --git a/apps/common/init/init_doc.py b/apps/common/init/init_doc.py index 156275e4ae6..fb4a4db3451 100644 --- a/apps/common/init/init_doc.py +++ b/apps/common/init/init_doc.py @@ -8,7 +8,7 @@ """ import hashlib -from django.urls import path, URLPattern +from django.urls import path, URLPattern, URLResolver from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView from maxkb.const import CONFIG @@ -16,6 +16,18 @@ chat_api_prefix = CONFIG.get_chat_path()[1:] + '/api/' +def flatten_url_patterns(patterns, prefix=''): + """ + 递归展开 urlpatterns,遇到 include() 产生的 URLResolver 时向下钻取, + 累加各层路由前缀,最终产出 (完整路由字符串, URLPattern) 元组。 + """ + for entry in patterns: + if isinstance(entry, URLResolver): + yield from flatten_url_patterns(entry.url_patterns, prefix + str(entry.pattern)) + elif isinstance(entry, URLPattern): + yield prefix + str(entry.pattern), entry + + def init_app_doc(system_urlpatterns): system_urlpatterns += [ path(f'{CONFIG.get_admin_path()[1:]}/api-doc/schema/', SpectacularAPIView.as_view(), name='schema'), @@ -35,17 +47,33 @@ def _swagger_ui_favicon(): return f'{CONFIG.get_chat_path()}/api-doc/swagger-ui-dist/favicon-32x32.png' +def build_curated_patterns(chat_urlpatterns, doc_names): + """按 name 集合从(递归展开后的)chat 路由里挑出 curated 端点,重建为带完整 path 的 URLPattern。""" + return [ + URLPattern(pattern=f'{chat_api_prefix}{full_path}', callback=url.callback, + default_args=url.default_args, name=url.name) + for full_path, url in flatten_url_patterns(chat_urlpatterns) + if doc_names.__contains__(getattr(url, 'name', None)) + ] + + def init_chat_doc(system_urlpatterns, chat_urlpatterns): + chat_path = CONFIG.get_chat_path()[1:] + v3_patterns = build_curated_patterns(chat_urlpatterns, ['v3_chat', 'v3_open', 'v3_profile']) + v2_patterns = build_curated_patterns(chat_urlpatterns, ['chat', 'open', 'profile', 'anonymous']) system_urlpatterns += [ - path(f'{CONFIG.get_chat_path()[1:]}/api-doc/schema/', - SpectacularAPIView.as_view(patterns=[ - URLPattern(pattern=f'{chat_api_prefix}{str(url.pattern)}', callback=url.callback, - default_args=url.default_args, - name=url.name) for url in chat_urlpatterns if - ['chat', 'open', 'profile'].__contains__(url.name)]), - name='chat_schema'), # schema的配置文件的路由,下面两个ui也是根据这个配置文件来生成的 - path(f'{CONFIG.get_chat_path()[1:]}/api-doc/', ChatSpectacularSwaggerView.as_view(url_name='chat_schema'), + # v3 curated 文档(主路径) + path(f'{chat_path}/api-doc/schema/', + SpectacularAPIView.as_view(patterns=v3_patterns), + name='chat_schema'), # schema的配置文件的路由,下面ui根据它生成 + path(f'{chat_path}/api-doc/', ChatSpectacularSwaggerView.as_view(url_name='chat_schema'), name='swagger-ui'), # swagger-ui的路由 + # v2 curated 文档(保留) + path(f'{chat_path}/api-doc/v2/schema/', + SpectacularAPIView.as_view(patterns=v2_patterns), + name='chat_schema_v2'), + path(f'{chat_path}/api-doc/v2/', ChatSpectacularSwaggerView.as_view(url_name='chat_schema_v2'), + name='swagger-ui-v2'), ]