q超长截断100字符、page/page_size健壮解析、types白名单;文章/工具/课程/接口结果URL对齐前端现行路由(/post、/utility、/course-learn、/open-api-detail)。
267 lines
9.9 KiB
Python
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)
|