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
+81 -10
View File
@@ -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):
View File
+91
View File
@@ -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'])