Files
chunyu_project/user/views/favorites.py
T
root 3618323192 fix(security): P1/P2 审计修复 + JWT HttpOnly Cookie 双模认证 + 限流
- 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)
2026-09-08 11:28:00 +08:00

193 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
)