- P0/P1 审计修复: 滑块验证码不再下发 x_position/成败即销毁 key、 user-login 补失败计数+滑块门控、限流标识改 X-Real-IP、 百度翻译 appkey 环境化、ChangeEmail/ChangePhone 补调 avalidate、 logs/tasks.py Count(filter=Q) 修复、chat 收藏 SSRF 内网黑名单 - P1 #6/7: token_blacklist + ROTATE_REFRESH_TOKENS 开启, /user/token/refresh/ 挂载 - #2 JWT HttpOnly Cookie 双模认证: user/cookie_auth.py 种/清 Cookie, user/authentication.py CookieOrHeaderJWTAuthentication(Bearer 优先/_COOKIE 兜底), user/views/token.py CookieTokenRefreshView + UserLogoutAPIView(/user/logout/), create_standardized_response 自动对含 token 的响应种 Cookie, 异步视图内 RefreshToken.for_user 全部 sync_to_async 包裹(修 SynchronousOnlyOperation 500), WS ChatConsumer 优先读 Cookie token - P2 #11 限流: utils/rate_limit.py 固定窗口频控, shorturl 生成 匿名10次/分+登录60次/分, 邮箱验证码 同邮箱60s1次+同IP10次/10min, 登录/注册验证码 错5次作废+成功即销毁防重放, 换绑邮箱/手机 同步落地, urls.py 补挂 shorturl 路由(此前 404)
193 lines
8.6 KiB
Python
193 lines
8.6 KiB
Python
from adrf.views import APIView
|
||
from rest_framework.permissions import IsAuthenticated
|
||
from rest_framework import status
|
||
|
||
from utils.response_codes import ResponseCode, create_standardized_response, create_standardized_error_response
|
||
from tool.models import Tool, ToolFavorite
|
||
from article.models import Article, ArticleFavorite
|
||
from learn.models import Course, CourseFavorite
|
||
from apidirectory.models import ApiItem, ApiFavorite
|
||
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
|
||
|
||
|
||
class MyFavoritesView(APIView):
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
@swagger_auto_schema(
|
||
tags=['收藏'],
|
||
operation_summary='获取我的收藏',
|
||
operation_description='获取当前用户的收藏列表,支持按类型筛选(tool/article/course/all)',
|
||
manual_parameters=[
|
||
openapi.Parameter('type', openapi.IN_QUERY, description='收藏类型(all/tool/article/course),默认all', type=openapi.TYPE_STRING),
|
||
],
|
||
responses={200: success_response, 401: unauthorized_response},
|
||
)
|
||
async def get(self, request):
|
||
content_type = request.query_params.get('type', 'all')
|
||
results = []
|
||
|
||
if content_type == 'all' or content_type == 'tool':
|
||
tool_favs = ToolFavorite.objects.filter(
|
||
user=request.user
|
||
).select_related('tool', 'tool__category').order_by('-created_at')
|
||
async for tf in tool_favs:
|
||
results.append({
|
||
'id': tf.tool.id,
|
||
'type': 'tool',
|
||
'title': tf.tool.name,
|
||
'description': tf.tool.description,
|
||
'icon': tf.tool.icon,
|
||
'color': getattr(tf.tool, 'color', ''),
|
||
'url_path': tf.tool.url_path,
|
||
'category': getattr(tf.tool.category, 'name', '') if tf.tool.category else '',
|
||
'created_at': tf.created_at.strftime('%Y-%m-%d %H:%M:%S'),
|
||
})
|
||
|
||
if content_type == 'all' or content_type == 'article':
|
||
article_favs = ArticleFavorite.objects.filter(
|
||
user=request.user
|
||
).select_related('article', 'article__author').order_by('-created_at')
|
||
async for af in article_favs:
|
||
results.append({
|
||
'id': af.article.id,
|
||
'type': 'article',
|
||
'title': af.article.title,
|
||
'description': af.article.excerpt or '',
|
||
'category': af.article.category,
|
||
'tags': getattr(af.article, 'tags', []),
|
||
'author': getattr(af.article.author, 'nickname', '') if af.article.author else '',
|
||
'author_id': af.article.author_id,
|
||
'views': af.article.views,
|
||
'likes': af.article.likes,
|
||
'cover_image': '',
|
||
'created_at': af.created_at.strftime('%Y-%m-%d %H:%M:%S'),
|
||
})
|
||
|
||
if content_type == 'all' or content_type == 'course':
|
||
course_favs = CourseFavorite.objects.filter(
|
||
user=request.user
|
||
).select_related('course', 'course__author').order_by('-created_at')
|
||
async for cf in course_favs:
|
||
results.append({
|
||
'id': cf.course.id,
|
||
'type': 'course',
|
||
'title': cf.course.title,
|
||
'description': cf.course.description,
|
||
'category': cf.course.category,
|
||
'level': cf.course.level,
|
||
'icon_name': getattr(cf.course, 'icon_name', ''),
|
||
'color': getattr(cf.course, 'color', ''),
|
||
'chapters_count': await cf.course.chapters.acount() if hasattr(cf.course, 'chapters') else 0,
|
||
'author': getattr(cf.course.author, 'nickname', '') if cf.course.author else '',
|
||
'author_id': cf.course.author_id,
|
||
'created_at': cf.created_at.strftime('%Y-%m-%d %H:%M:%S'),
|
||
})
|
||
|
||
if content_type == 'all' or content_type == 'api':
|
||
api_favs = ApiFavorite.objects.filter(
|
||
user=request.user
|
||
).select_related('api_item').order_by('-created_at')
|
||
async for af in api_favs:
|
||
results.append({
|
||
'id': af.api_item.id,
|
||
'type': 'api',
|
||
'title': af.api_item.name,
|
||
'description': af.api_item.description or '',
|
||
'icon': 'ApiOutlined',
|
||
'color': af.api_item.color or '#faad14',
|
||
'url_path': af.api_item.url_path or '',
|
||
'category': getattr(af.api_item.category, 'name', '') if af.api_item.category else '',
|
||
'created_at': af.created_at.strftime('%Y-%m-%d %H:%M:%S'),
|
||
})
|
||
|
||
counts = {
|
||
'total': len(results),
|
||
'tool': await ToolFavorite.objects.filter(user=request.user).acount(),
|
||
'article': await ArticleFavorite.objects.filter(user=request.user).acount(),
|
||
'course': await CourseFavorite.objects.filter(user=request.user).acount(),
|
||
'api': await ApiFavorite.objects.filter(user=request.user).acount(),
|
||
}
|
||
|
||
return create_standardized_response(
|
||
data={'results': results, 'counts': counts},
|
||
code=ResponseCode.SUCCESS
|
||
)
|
||
|
||
|
||
class FavoriteToggleView(APIView):
|
||
"""统一收藏切换端点:按 type + id 切换 tool/article/course/api 收藏状态。
|
||
|
||
修复:Android 端 FavoritesApi.toggleFavorite 调用的 user/favorites/toggle/
|
||
此前不存在(404)。响应契约与客户端对齐:{is_favorited, favorites_count}。
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
@swagger_auto_schema(
|
||
tags=['收藏'],
|
||
operation_summary='切换收藏状态',
|
||
operation_description='按类型切换收藏(tool/article/course/api),返回切换后的状态',
|
||
request_body=openapi.Schema(
|
||
type=openapi.TYPE_OBJECT,
|
||
required=['type', 'target_id'],
|
||
properties={
|
||
'type': openapi.Schema(type=openapi.TYPE_STRING, enum=['tool', 'article', 'course', 'api']),
|
||
'target_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='目标对象 ID'),
|
||
},
|
||
),
|
||
responses={200: success_response, 400: error_response, 401: unauthorized_response, 404: not_found_response},
|
||
)
|
||
async def post(self, request):
|
||
fav_type = request.data.get('type')
|
||
target_id = request.data.get('target_id')
|
||
|
||
if fav_type not in ('tool', 'article', 'course', 'api'):
|
||
return create_standardized_error_response(
|
||
message='type 必须为 tool/article/course/api 之一',
|
||
code=ResponseCode.PARAMETER_ERROR,
|
||
status_code=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
try:
|
||
target_id = int(target_id)
|
||
except (TypeError, ValueError):
|
||
return create_standardized_error_response(
|
||
message='target_id 必须为整数',
|
||
code=ResponseCode.PARAMETER_ERROR,
|
||
status_code=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
|
||
# (模型, FK 字段名, 目标模型)
|
||
mapping = {
|
||
'tool': (ToolFavorite, 'tool', Tool),
|
||
'article': (ArticleFavorite, 'article', Article),
|
||
'course': (CourseFavorite, 'course', Course),
|
||
'api': (ApiFavorite, 'api_item', ApiItem),
|
||
}
|
||
model, fk_field, target_model = mapping[fav_type]
|
||
|
||
try:
|
||
await target_model.objects.aget(id=target_id)
|
||
except target_model.DoesNotExist:
|
||
return create_standardized_error_response(
|
||
message='目标对象不存在',
|
||
code=ResponseCode.PARAMETER_ERROR,
|
||
status_code=status.HTTP_404_NOT_FOUND
|
||
)
|
||
|
||
existing = await model.objects.filter(user=request.user, **{fk_field: target_id}).afirst()
|
||
if existing is not None:
|
||
await model.objects.filter(user=request.user, **{fk_field: target_id}).adelete()
|
||
favorited = False
|
||
else:
|
||
await model.objects.acreate(user=request.user, **{fk_field: target_id})
|
||
favorited = True
|
||
|
||
favorites_count = await model.objects.filter(user=request.user).acount()
|
||
|
||
return create_standardized_response(
|
||
data={'is_favorited': favorited, 'favorites_count': favorites_count, 'type': fav_type, 'target_id': target_id},
|
||
code=ResponseCode.SUCCESS
|
||
)
|