diff --git a/article/serializers.py b/article/serializers.py index bfb6a24..ea3a99b 100644 --- a/article/serializers.py +++ b/article/serializers.py @@ -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() diff --git a/article/tests/__init__.py b/article/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/article/tests/test_following_feed.py b/article/tests/test_following_feed.py new file mode 100644 index 0000000..dffa8c1 --- /dev/null +++ b/article/tests/test_following_feed.py @@ -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) diff --git a/article/views.py b/article/views.py index e8a537d..955449f 100644 --- a/article/views.py +++ b/article/views.py @@ -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), diff --git a/learn/serializers.py b/learn/serializers.py index 394506e..9f2a60a 100644 --- a/learn/serializers.py +++ b/learn/serializers.py @@ -1,9 +1,67 @@ from adrf.serializers import ( ModelSerializer, SerializerMethodField, ) +from asgiref.sync import sync_to_async + from .models import Course, Chapter, ChapterContent, CourseMaterial +def _author_display(obj): + """同步取作者展示名(跑在 sync_to_async 线程里,避免 ASGI 下 SynchronousOnlyOperation)。""" + try: + author = obj.author + except Exception: + return '' + if author is None: + return '' + try: + full = author.get_full_name() + except Exception: + full = '' + try: + username = author.username + except Exception: + username = '' + return full or username or '' + + +def parse_duration_minutes(raw): + """把章节 duration 字符串解析为分钟数;解析不出返回 None。 + + 存量格式为中文字符串(如 '30分钟'),纯数字视为分钟。 + total_minutes 只对可解析章节求和,不可解析章节不计入(不伪造)。 + """ + import re + if raw is None: + return None + s = str(raw).strip() + if not s: + return None + m = re.search(r'(\d+(?:\.\d+)?)', s) + if not m: + return None + try: + return int(float(m.group(1))) + except (ValueError, OverflowError): + return None + + +def _course_total_minutes(obj): + """同步聚合课程总分钟数(跑在 sync_to_async 线程里)。""" + try: + durations = list(obj.chapters.values_list('duration', flat=True)) + except Exception: + return None + total = 0 + any_parsed = False + for d in durations: + m = parse_duration_minutes(d) + if m is not None: + total += m + any_parsed = True + return total if any_parsed else None + + class ChapterSerializer(ModelSerializer): video_poster_url = SerializerMethodField() video_local_url = SerializerMethodField() @@ -100,6 +158,7 @@ class CourseListSerializer(ModelSerializer): author_name = SerializerMethodField() chapters_count = SerializerMethodField() students_count = SerializerMethodField() + total_minutes = SerializerMethodField() cover_image_url = SerializerMethodField() class Meta: @@ -107,7 +166,7 @@ class CourseListSerializer(ModelSerializer): fields = [ 'id', 'title', 'description', 'category', 'level', 'cover_image_url', 'color', 'icon_name', 'author_name', - 'chapters_count', 'students_count', 'status', + 'chapters_count', 'students_count', 'total_minutes', 'status', 'sort_order', 'is_hot', 'is_new', 'created_at', 'updated_at', ] @@ -115,7 +174,15 @@ class CourseListSerializer(ModelSerializer): return await obj.chapters.acount() async def get_students_count(self, obj): - return 0 + # 从章节阅读记录去重统计真实在学人数 + from user.models import FUser + return await FUser.objects.filter( + chapter_reads__course=obj + ).distinct().acount() + + async def get_total_minutes(self, obj): + # 可解析章节 duration 求和;全不可解析返回 None(前端不显示,不伪造) + return await sync_to_async(_course_total_minutes)(obj) async def get_cover_image_url(self, obj): if obj.cover_image: @@ -126,9 +193,7 @@ class CourseListSerializer(ModelSerializer): return '' async def get_author_name(self, obj): - if obj.author: - return obj.author.get_full_name() or obj.author.username - return '' + return await sync_to_async(_author_display)(obj) class CourseDetailSerializer(ModelSerializer): @@ -136,6 +201,7 @@ class CourseDetailSerializer(ModelSerializer): chapters = ChapterSerializer(many=True, read_only=True) chapters_count = SerializerMethodField() students_count = SerializerMethodField() + total_minutes = SerializerMethodField() cover_image_url = SerializerMethodField() class Meta: @@ -143,7 +209,7 @@ class CourseDetailSerializer(ModelSerializer): fields = [ 'id', 'title', 'description', 'category', 'level', 'cover_image_url', 'color', 'icon_name', 'author_name', - 'chapters', 'chapters_count', 'students_count', 'status', + 'chapters', 'chapters_count', 'students_count', 'total_minutes', 'status', 'sort_order', 'is_hot', 'is_new', 'created_at', 'updated_at', ] @@ -151,7 +217,14 @@ class CourseDetailSerializer(ModelSerializer): return await obj.chapters.acount() async def get_students_count(self, obj): - return 0 + # 从章节阅读记录去重统计真实在学人数 + from user.models import FUser + return await FUser.objects.filter( + chapter_reads__course=obj + ).distinct().acount() + + async def get_total_minutes(self, obj): + return await sync_to_async(_course_total_minutes)(obj) async def get_cover_image_url(self, obj): if obj.cover_image: @@ -162,9 +235,7 @@ class CourseDetailSerializer(ModelSerializer): return '' async def get_author_name(self, obj): - if obj.author: - return obj.author.get_full_name() or obj.author.username - return '' + return await sync_to_async(_author_display)(obj) class CourseCreateUpdateSerializer(ModelSerializer): diff --git a/learn/tests/__init__.py b/learn/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/learn/tests/test_c06_hooks.py b/learn/tests/test_c06_hooks.py new file mode 100644 index 0000000..ac4aa8a --- /dev/null +++ b/learn/tests/test_c06_hooks.py @@ -0,0 +1,91 @@ +"""C-06 签到幂等 + 学习聚合去重回归。""" +from django.test import TestCase +from rest_framework.test import APIClient + +from learn.models import Chapter, ChapterRead, Course +from user.models import DailyCheckin, FUser + + +class CheckinIdempotentTest(TestCase): + def setUp(self): + self.client = APIClient() + self.u = FUser.objects.create_user(username='c06sa', password='x12345678') + + def test_double_checkin_second_400_no_double_points(self): + self.client.force_authenticate(user=self.u) + r1 = self.client.post('/user/wallet/checkin/') + self.assertEqual(r1.status_code, 200) + earned = r1.json()['data']['earned'] + self.u.refresh_from_db() + p1 = self.u.points + self.assertGreaterEqual(p1, earned) + r2 = self.client.post('/user/wallet/checkin/') + self.assertEqual(r2.status_code, 400) + self.u.refresh_from_db() + self.assertEqual(self.u.points, p1) + self.assertEqual(DailyCheckin.objects.filter(user=self.u).count(), 1) + + def test_checkin_status_reflects_signed(self): + self.client.force_authenticate(user=self.u) + self.client.post('/user/wallet/checkin/') + r = self.client.get('/user/wallet/checkin/status/') + self.assertEqual(r.status_code, 200) + self.assertTrue(r.json()['data']['signed_today']) + + def test_anonymous_checkin_401(self): + r = self.client.post('/user/wallet/checkin/') + self.assertEqual(r.status_code, 401) + + +class LearnersCountTest(TestCase): + def setUp(self): + self.client = APIClient() + self.u1 = FUser.objects.create_user(username='c06la', password='x12345678') + self.u2 = FUser.objects.create_user(username='c06lb', password='x12345678') + self.course = Course.objects.create(title='C06聚合课', author=self.u1, status='published') + self.ch1 = Chapter.objects.create(course=self.course, title='第一章') + self.ch2 = Chapter.objects.create(course=self.course, title='第二章') + + def test_cross_read_dedup_two_users(self): + ChapterRead.objects.create(user=self.u1, chapter=self.ch1, course=self.course) + ChapterRead.objects.create(user=self.u1, chapter=self.ch2, course=self.course) + ChapterRead.objects.create(user=self.u2, chapter=self.ch1, course=self.course) + r = self.client.get('/learn/courses/') + self.assertEqual(r.status_code, 200) + items = {x['title']: x for x in r.json()['data']} + self.assertEqual(items['C06聚合课']['students_count'], 2) + + def test_no_read_zero(self): + r = self.client.get('/learn/courses/') + self.assertEqual(r.status_code, 200) + items = {x['title']: x for x in r.json()['data']} + self.assertEqual(items['C06聚合课']['students_count'], 0) + + def test_detail_students_count_matches(self): + ChapterRead.objects.create(user=self.u1, chapter=self.ch1, course=self.course) + r = self.client.get(f'/learn/courses/{self.course.id}/') + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json()['data']['students_count'], 1) + + +class TotalMinutesTest(TestCase): + def setUp(self): + self.client = APIClient() + self.u = FUser.objects.create_user(username='c06ma', password='x12345678') + + def test_sums_parsable_durations(self): + course = Course.objects.create(title='C06时长课', author=self.u, status='published') + Chapter.objects.create(course=course, title='C1', duration='30分钟') + Chapter.objects.create(course=course, title='C2', duration='45分钟') + r = self.client.get('/learn/courses/') + self.assertEqual(r.status_code, 200) + items = {x['title']: x for x in r.json()['data']} + self.assertEqual(items['C06时长课']['total_minutes'], 75) + + def test_unparsable_returns_none(self): + course = Course.objects.create(title='C06无时长课', author=self.u, status='published') + Chapter.objects.create(course=course, title='C1', duration='') + Chapter.objects.create(course=course, title='C2', duration='未知') + r = self.client.get(f'/learn/courses/{course.id}/') + self.assertEqual(r.status_code, 200) + self.assertIsNone(r.json()['data']['total_minutes'])