Files
chunyu_project/search/views.py
T
chunyu 1e95db0bda fix(C-07):搜索边界收口+结果跳转死链修复
q超长截断100字符、page/page_size健壮解析、types白名单;文章/工具/课程/接口结果URL对齐前端现行路由(/post、/utility、/course-learn、/open-api-detail)。
2026-09-15 15:20:19 +08:00

267 lines
9.9 KiB
Python

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)