feat(C-06):关注feed+课程聚合修复

文章列表新增author_user_id/is_following与feed=following过滤;课程students_count改真实在学人数去重统计、新增total_minutes(不可解析不伪造);author_name走sync_to_async防ASGI同步阻塞。
This commit is contained in:
chunyu
2026-09-15 15:20:23 +08:00
parent 1e95db0bda
commit 37cad3947d
7 changed files with 264 additions and 11 deletions
+11 -1
View File
@@ -45,10 +45,12 @@ class ArticleCommentSerializer(ModelSerializer):
class ArticleListSerializer(ModelSerializer):
author_name = CharField(source='author.first_name', read_only=True)
author_avatar = ImageField(source='author.avatar', read_only=True)
author_user_id = serializers.PrimaryKeyRelatedField(source='author', read_only=True)
comments_count = SerializerMethodField()
read_time = SerializerMethodField()
is_hot = SerializerMethodField()
is_new = SerializerMethodField()
is_following = SerializerMethodField()
publish_date = DateTimeField(source='created_at', format='%Y-%m-%d', read_only=True)
image = SerializerMethodField()
@@ -56,10 +58,18 @@ class ArticleListSerializer(ModelSerializer):
model = Article
fields = [
'id', 'title', 'excerpt', 'category', 'author_name', 'author_avatar',
'views', 'likes', 'comments_count', 'publish_date', 'image', 'tags',
'author_user_id', 'views', 'likes', 'comments_count', 'publish_date', 'image', 'tags',
'read_time', 'is_featured', 'is_hot', 'is_new', 'is_top', 'status',
'is_following',
]
async def get_is_following(self, obj):
request = self.context.get('request')
if request and request.user.is_authenticated:
from user.models import Follow
return await Follow.objects.filter(follower=request.user, following=obj.author_id).aexists()
return False
async def get_comments_count(self, obj):
return await obj.comments.acount()
View File
+69
View File
@@ -0,0 +1,69 @@
"""C-06 关注 feed 回归:关注/未关注/互关/取关/匿名/分页对齐。"""
from django.test import TestCase
from rest_framework.test import APIClient
from article.models import Article
from user.models import Follow, FUser
class FollowingFeedTest(TestCase):
def setUp(self):
self.client = APIClient()
self.a = FUser.objects.create_user(username='c06fa', password='x12345678')
self.b = FUser.objects.create_user(username='c06fb', password='x12345678')
self.c = FUser.objects.create_user(username='c06fc', password='x12345678')
self.art_b = Article.objects.create(title='B文', content='x' * 500, author=self.b, status='published')
self.art_c = Article.objects.create(title='C文', content='y' * 500, author=self.c, status='published')
def test_following_only_shows_followed(self):
Follow.objects.create(follower=self.a, following=self.b)
self.client.force_authenticate(user=self.a)
r = self.client.get('/article/articles/', {'feed': 'following'})
self.assertEqual(r.status_code, 200)
titles = [x['title'] for x in r.json()['data']['results']]
self.assertEqual(titles, ['B文'])
def test_unfollowed_empty_state(self):
self.client.force_authenticate(user=self.a)
r = self.client.get('/article/articles/', {'feed': 'following'})
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()['data']['count'], 0)
self.assertEqual(r.json()['data']['results'], [])
def test_mutual_follow_both_visible(self):
Follow.objects.create(follower=self.a, following=self.b)
Follow.objects.create(follower=self.a, following=self.c)
self.client.force_authenticate(user=self.a)
r = self.client.get('/article/articles/', {'feed': 'following'})
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()['data']['count'], 2)
def test_unfollow_removes_from_feed(self):
f = Follow.objects.create(follower=self.a, following=self.b)
f.delete()
self.client.force_authenticate(user=self.a)
r = self.client.get('/article/articles/', {'feed': 'following'})
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()['data']['count'], 0)
def test_anonymous_following_empty(self):
Follow.objects.create(follower=self.a, following=self.b)
r = self.client.get('/article/articles/', {'feed': 'following'})
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()['data']['count'], 0)
def test_normal_list_unaffected(self):
Follow.objects.create(follower=self.a, following=self.b)
self.client.force_authenticate(user=self.a)
r = self.client.get('/article/articles/')
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()['data']['count'], 2)
def test_draft_not_in_feed(self):
Follow.objects.create(follower=self.a, following=self.b)
self.art_b.status = 'draft'
self.art_b.save(update_fields=['status'])
self.client.force_authenticate(user=self.a)
r = self.client.get('/article/articles/', {'feed': 'following'})
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()['data']['count'], 0)
+12
View File
@@ -77,6 +77,17 @@ class ArticleListCreateView(generics.ListCreateAPIView):
Q(tags__overlap=[search])
)
# 关注 feed:仅返回当前用户关注作者的文章
feed = self.request.query_params.get('feed')
if feed == 'following':
if not self.request.user.is_authenticated:
return Article.objects.none()
from user.models import Follow
followed_ids = Follow.objects.filter(
follower=self.request.user
).values_list('following_id', flat=True)
qs = qs.filter(author_id__in=followed_ids)
ordering = self.request.query_params.get('ordering', '-created_at')
return qs.order_by('-is_top', ordering)
@@ -94,6 +105,7 @@ class ArticleListCreateView(generics.ListCreateAPIView):
openapi.Parameter('category', openapi.IN_QUERY, description='文章分类筛选', type=openapi.TYPE_STRING),
openapi.Parameter('tag', openapi.IN_QUERY, description='文章标签筛选', type=openapi.TYPE_STRING),
openapi.Parameter('search', openapi.IN_QUERY, description='搜索关键词(标题、摘要、标签)', type=openapi.TYPE_STRING),
openapi.Parameter('feed', openapi.IN_QUERY, description='feed=following 时仅返回当前用户关注作者的文章(需登录)', type=openapi.TYPE_STRING),
openapi.Parameter('ordering', openapi.IN_QUERY, description='排序方式,默认 -created_at', type=openapi.TYPE_STRING),
openapi.Parameter('page', openapi.IN_QUERY, description='页码', type=openapi.TYPE_INTEGER),
openapi.Parameter('page_size', openapi.IN_QUERY, description='每页数量,默认10,最大100', type=openapi.TYPE_INTEGER),