from adrf.views import APIView from rest_framework.response import Response from rest_framework import status from rest_framework.permissions import AllowAny from django.db.models import Q from django.core.paginator import Paginator from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi from chunyu_project.common_schemas import success_response, error_response, unauthorized_response, not_found_response from article.models import Article from tool.models import Tool from learn.models import Course from apidirectory.models import ApiItem from utils.response_codes import ResponseCode, create_standardized_response SORT_MAPPING = { '1': 'relevance', '2': 'views', '3': 'newest', } # C-07 边界收口:q 超长截断、page/page_size 健壮解析、types 白名单。 MAX_Q_LEN = 100 MAX_PAGE_SIZE = 50 DEFAULT_PAGE_SIZE = 12 CONTENT_TYPES = ('all', 'article', 'tool', 'course', 'api') def normalize_sort(sort_value): return SORT_MAPPING.get(sort_value, sort_value) class GlobalSearchView(APIView): permission_classes = [AllowAny] @swagger_auto_schema( tags=['搜索'], operation_summary='全局搜索', operation_description='根据关键词搜索文章、工具、课程和API,支持分页和排序', manual_parameters=[ openapi.Parameter('q', openapi.IN_QUERY, description='搜索关键词', type=openapi.TYPE_STRING, required=True), openapi.Parameter('type', openapi.IN_QUERY, description='内容类型筛选: all/article/tool/course/api', type=openapi.TYPE_STRING), openapi.Parameter('page', openapi.IN_QUERY, description='页码', type=openapi.TYPE_INTEGER), openapi.Parameter('page_size', openapi.IN_QUERY, description='每页数量', type=openapi.TYPE_INTEGER), openapi.Parameter('sort', openapi.IN_QUERY, description='排序方式: relevance/views/newest (或数字 1/2/3)', type=openapi.TYPE_STRING), ], responses={200: success_response} ) async def get(self, request): raw_q = request.query_params.get('q', '') if not isinstance(raw_q, str): raw_q = str(raw_q) q = raw_q.strip()[:MAX_Q_LEN] raw_type = request.query_params.get('type', 'all') if not isinstance(raw_type, str): raw_type = 'all' content_type = raw_type.strip().lower() or 'all' if content_type not in CONTENT_TYPES: content_type = 'all' try: page = int(request.query_params.get('page', 1)) except (TypeError, ValueError): page = 1 try: page_size = int(request.query_params.get('page_size', DEFAULT_PAGE_SIZE)) except (TypeError, ValueError): page_size = DEFAULT_PAGE_SIZE page = max(page, 1) page_size = min(max(page_size, 1), MAX_PAGE_SIZE) raw_sort = request.query_params.get('sort', 'relevance') if not isinstance(raw_sort, str): raw_sort = str(raw_sort) sort = normalize_sort(raw_sort) if not q: return create_standardized_response(data={ 'results': [], 'total': 0, 'page': page, 'page_size': page_size, }, code=ResponseCode.SUCCESS) results = [] if content_type == 'all' or content_type == 'article': articles = Article.objects.filter( Q(title__icontains=q) | Q(excerpt__icontains=q) | Q(content__icontains=q) ).filter(status='published').order_by('-created_at')[:100] async for article in articles: cover_url = '' if article.cover_image and hasattr(article.cover_image, 'url'): cover_url = request.build_absolute_uri(article.cover_image.url) excerpt = article.excerpt or '' if not excerpt and article.content: excerpt = article.content[:120] + '...' if len(article.content) > 120 else article.content results.append({ 'id': article.id, 'type': '文章', 'title': article.title, 'desc': excerpt, 'cover': cover_url, 'views': article.views or 0, 'likes': article.likes or 0, 'url': f'/post/{article.id}', }) if content_type == 'all' or content_type == 'tool': tools = Tool.objects.filter( Q(name__icontains=q) | Q(description__icontains=q) ).order_by('-created_at')[:100] async for tool in tools: results.append({ 'id': tool.id, 'type': '工具', 'title': tool.name, 'desc': tool.description or '', 'cover': '', 'views': tool.usage_count or 0, 'likes': 0, 'url': tool.url_path or '/utility', }) if content_type == 'all' or content_type == 'course': courses = Course.objects.filter( Q(title__icontains=q) | Q(description__icontains=q) ).order_by('-created_at')[:100] async for course in courses: cover_url = '' if course.cover_image and hasattr(course.cover_image, 'url'): cover_url = request.build_absolute_uri(course.cover_image.url) results.append({ 'id': course.id, 'type': '课程', 'title': course.title, 'desc': course.description or '', 'cover': cover_url, 'views': 0, 'likes': 0, 'url': f'/course-learn?id={course.id}', }) if content_type == 'all' or content_type == 'api': apis = ApiItem.objects.filter( Q(name__icontains=q) | Q(description__icontains=q) | Q(url_path__icontains=q) ).filter(is_enabled=True).order_by('-created_at')[:100] async for api in apis: desc = api.description or f'{api.method} {api.url_path}' results.append({ 'id': api.id, 'type': 'API', 'title': api.name, 'desc': desc, 'cover': api.image_url or '', 'views': api.views_count or 0, 'likes': 0, 'url': f'/open-api-detail/{api.id}', }) if sort == 'views': results.sort(key=lambda x: x['views'], reverse=True) elif sort == 'newest': pass else: results.sort(key=lambda x: x['views'] + x['likes'] * 10, reverse=True) paginator = Paginator(results, page_size) total = len(results) try: paginated_results = paginator.page(page) except Exception: paginated_results = [] return create_standardized_response(data={ 'results': list(paginated_results) if hasattr(paginated_results, '__iter__') else paginated_results, 'total': total, 'page': page, 'page_size': page_size, }, code=ResponseCode.SUCCESS) class SearchSuggestionsView(APIView): permission_classes = [AllowAny] @swagger_auto_schema( tags=['搜索'], operation_summary='搜索建议', operation_description='根据输入前缀返回搜索建议列表', manual_parameters=[ openapi.Parameter('q', openapi.IN_QUERY, description='搜索前缀', type=openapi.TYPE_STRING, required=True), openapi.Parameter('limit', openapi.IN_QUERY, description='返回数量上限', type=openapi.TYPE_INTEGER), ], responses={200: success_response} ) async def get(self, request): raw_q = request.query_params.get('q', '') if not isinstance(raw_q, str): raw_q = str(raw_q) q = raw_q.strip()[:MAX_Q_LEN] try: limit = int(request.query_params.get('limit', 8)) except (TypeError, ValueError): limit = 8 limit = min(max(limit, 1), MAX_PAGE_SIZE) if not q: return create_standardized_response(data=[], code=ResponseCode.SUCCESS) suggestions = set() article_titles = Article.objects.filter( title__icontains=q ).filter(status='published').values_list('title', flat=True)[:5] suggestions.update([t async for t in article_titles]) tool_names = Tool.objects.filter( name__icontains=q ).values_list('name', flat=True)[:5] suggestions.update([t async for t in tool_names]) course_titles = Course.objects.filter( title__icontains=q ).values_list('title', flat=True)[:5] suggestions.update([t async for t in course_titles]) api_names = ApiItem.objects.filter( name__icontains=q ).filter(is_enabled=True).values_list('name', flat=True)[:5] suggestions.update([t async for t in api_names]) result = list(suggestions)[:limit] return create_standardized_response(data=result, code=ResponseCode.SUCCESS) class HotKeywordsView(APIView): permission_classes = [AllowAny] @swagger_auto_schema( tags=['搜索'], operation_summary='获取热门搜索关键词', operation_description='返回热门搜索关键词列表', responses={200: success_response} ) async def get(self, request): hot_keywords = [ 'React Hooks', 'RESTful API', 'TypeScript', 'Docker部署', 'GraphQL', '微服务架构', 'CI/CD', '性能优化', 'Vue3', 'Python', ] return create_standardized_response(data=hot_keywords, code=ResponseCode.SUCCESS)