@@ -0,0 +1,21 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import ContentInteraction, ContentItem, VideoProgress
|
||||
|
||||
|
||||
@admin.register(ContentItem)
|
||||
class ContentItemAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"title",
|
||||
"kind",
|
||||
"ability_dimension",
|
||||
"discipline",
|
||||
"duration_seconds",
|
||||
"is_published",
|
||||
)
|
||||
list_filter = ("kind", "ability_dimension", "discipline", "is_published")
|
||||
search_fields = ("title", "summary", "body")
|
||||
|
||||
|
||||
admin.site.register(ContentInteraction)
|
||||
admin.site.register(VideoProgress)
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ContentConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'content'
|
||||
@@ -0,0 +1,51 @@
|
||||
# Generated by Django 4.2.23 on 2026-08-08 11:15
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ContentItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('slug', models.SlugField(unique=True)),
|
||||
('title', models.CharField(max_length=160)),
|
||||
('kind', models.CharField(choices=[('video', '视频'), ('knowledge', '知识卡片'), ('person', '数学人物')], max_length=16)),
|
||||
('summary', models.TextField(blank=True)),
|
||||
('body', models.TextField(blank=True)),
|
||||
('cover_url', models.URLField(blank=True)),
|
||||
('media_url', models.URLField(blank=True)),
|
||||
('topics', models.JSONField(blank=True, default=list)),
|
||||
('source', models.CharField(blank=True, max_length=300)),
|
||||
('is_published', models.BooleanField(default=False)),
|
||||
('published_at', models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-published_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ContentInteraction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('action', models.CharField(choices=[('view', '浏览'), ('favorite', '收藏'), ('complete', '完成')], max_length=16)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('content', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interactions', to='content.contentitem')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='contentinteraction',
|
||||
constraint=models.UniqueConstraint(fields=('user', 'content', 'action'), name='unique_content_interaction'),
|
||||
),
|
||||
]
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
# Generated by Django 4.2.23 on 2026-08-08 11:47
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('content', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='contentitem',
|
||||
name='ability_dimension',
|
||||
field=models.CharField(blank=True, choices=[('vision', '数学眼光'), ('humanities', '数学人文'), ('detection', '数学侦探'), ('modeling', '数学建模'), ('connection', '数学联结')], max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contentitem',
|
||||
name='author',
|
||||
field=models.CharField(blank=True, max_length=120),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contentitem',
|
||||
name='comment_count',
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contentitem',
|
||||
name='discipline',
|
||||
field=models.CharField(blank=True, max_length=80),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contentitem',
|
||||
name='discipline_icon',
|
||||
field=models.CharField(blank=True, max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contentitem',
|
||||
name='duration_seconds',
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contentitem',
|
||||
name='module',
|
||||
field=models.CharField(blank=True, max_length=40),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contentitem',
|
||||
name='sub_category',
|
||||
field=models.CharField(blank=True, max_length=80),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contentitem',
|
||||
name='view_count',
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VideoProgress',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('position_seconds', models.PositiveIntegerField(default=0)),
|
||||
('completed', models.BooleanField(default=False)),
|
||||
('reward_granted', models.BooleanField(default=False)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('completed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('content', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='video_progress', to='content.contentitem')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='video_progress', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='videoprogress',
|
||||
constraint=models.UniqueConstraint(fields=('user', 'content'), name='unique_user_video_progress'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,85 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class ContentItem(models.Model):
|
||||
class Ability(models.TextChoices):
|
||||
VISION = "vision", "数学眼光"
|
||||
HUMANITIES = "humanities", "数学人文"
|
||||
DETECTION = "detection", "数学侦探"
|
||||
MODELING = "modeling", "数学建模"
|
||||
CONNECTION = "connection", "数学联结"
|
||||
|
||||
class Kind(models.TextChoices):
|
||||
VIDEO = "video", "视频"
|
||||
KNOWLEDGE = "knowledge", "知识卡片"
|
||||
PERSON = "person", "数学人物"
|
||||
|
||||
slug = models.SlugField(unique=True)
|
||||
title = models.CharField(max_length=160)
|
||||
kind = models.CharField(max_length=16, choices=Kind.choices)
|
||||
summary = models.TextField(blank=True)
|
||||
body = models.TextField(blank=True)
|
||||
cover_url = models.URLField(blank=True)
|
||||
media_url = models.URLField(blank=True)
|
||||
topics = models.JSONField(default=list, blank=True)
|
||||
source = models.CharField(max_length=300, blank=True)
|
||||
ability_dimension = models.CharField(
|
||||
max_length=20, choices=Ability.choices, blank=True
|
||||
)
|
||||
module = models.CharField(max_length=40, blank=True)
|
||||
sub_category = models.CharField(max_length=80, blank=True)
|
||||
discipline = models.CharField(max_length=80, blank=True)
|
||||
discipline_icon = models.CharField(max_length=10, blank=True)
|
||||
author = models.CharField(max_length=120, blank=True)
|
||||
duration_seconds = models.PositiveIntegerField(default=0)
|
||||
view_count = models.PositiveIntegerField(default=0)
|
||||
comment_count = models.PositiveIntegerField(default=0)
|
||||
is_published = models.BooleanField(default=False)
|
||||
published_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-published_at"]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class ContentInteraction(models.Model):
|
||||
class Action(models.TextChoices):
|
||||
VIEW = "view", "浏览"
|
||||
FAVORITE = "favorite", "收藏"
|
||||
COMPLETE = "complete", "完成"
|
||||
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||
content = models.ForeignKey(ContentItem, on_delete=models.CASCADE, related_name="interactions")
|
||||
action = models.CharField(max_length=16, choices=Action.choices)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("user", "content", "action"), name="unique_content_interaction"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class VideoProgress(models.Model):
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="video_progress"
|
||||
)
|
||||
content = models.ForeignKey(
|
||||
ContentItem, on_delete=models.CASCADE, related_name="video_progress"
|
||||
)
|
||||
position_seconds = models.PositiveIntegerField(default=0)
|
||||
completed = models.BooleanField(default=False)
|
||||
reward_granted = models.BooleanField(default=False)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
completed_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("user", "content"), name="unique_user_video_progress"
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,68 @@
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from progression.models import RewardTransaction, UserAbility
|
||||
|
||||
from .models import ContentInteraction, ContentItem, VideoProgress
|
||||
|
||||
|
||||
ABILITY_TO_DIMENSION = {
|
||||
ContentItem.Ability.VISION: UserAbility.Dimension.VISION,
|
||||
ContentItem.Ability.HUMANITIES: UserAbility.Dimension.HUMANITIES,
|
||||
ContentItem.Ability.DETECTION: UserAbility.Dimension.DETECTION,
|
||||
ContentItem.Ability.MODELING: UserAbility.Dimension.MODELING,
|
||||
ContentItem.Ability.CONNECTION: UserAbility.Dimension.CONNECTION,
|
||||
}
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def complete_video(user, content):
|
||||
if content.kind != ContentItem.Kind.VIDEO:
|
||||
raise ValidationError("只有视频内容可以提交观看完成")
|
||||
|
||||
progress, _ = VideoProgress.objects.select_for_update().get_or_create(
|
||||
user=user,
|
||||
content=content,
|
||||
)
|
||||
progress.position_seconds = max(progress.position_seconds, content.duration_seconds)
|
||||
progress.completed = True
|
||||
progress.completed_at = progress.completed_at or timezone.now()
|
||||
|
||||
reward = None
|
||||
dimension = ABILITY_TO_DIMENSION.get(content.ability_dimension)
|
||||
if dimension and not progress.reward_granted:
|
||||
transaction_key = f"video-complete:{content.id}"
|
||||
reward_tx, created = RewardTransaction.objects.get_or_create(
|
||||
user=user,
|
||||
idempotency_key=transaction_key,
|
||||
defaults={
|
||||
"source": "video_complete",
|
||||
"rewards": {"ability": dimension, "fragments": 1},
|
||||
},
|
||||
)
|
||||
if created:
|
||||
ability, _ = UserAbility.objects.select_for_update().get_or_create(
|
||||
user=user,
|
||||
dimension=dimension,
|
||||
)
|
||||
ability.fragments += 1
|
||||
ability.save(update_fields=["fragments"])
|
||||
progress.reward_granted = True
|
||||
reward = reward_tx.rewards
|
||||
|
||||
progress.save(
|
||||
update_fields=[
|
||||
"position_seconds",
|
||||
"completed",
|
||||
"completed_at",
|
||||
"reward_granted",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
ContentInteraction.objects.get_or_create(
|
||||
user=user,
|
||||
content=content,
|
||||
action=ContentInteraction.Action.COMPLETE,
|
||||
)
|
||||
return progress, reward
|
||||
@@ -0,0 +1,64 @@
|
||||
import pytest
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from accounts.models import User
|
||||
from content.models import ContentInteraction, ContentItem, VideoProgress
|
||||
from content.services import complete_video
|
||||
from progression.models import RewardTransaction, UserAbility
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_complete_video_重复提交只发放一次能力碎片():
|
||||
user = User.objects.create_user(
|
||||
username="video_user",
|
||||
password="StrongPass_2026",
|
||||
nickname="视频用户",
|
||||
)
|
||||
video = ContentItem.objects.create(
|
||||
slug="video-modeling",
|
||||
title="建模视频",
|
||||
kind=ContentItem.Kind.VIDEO,
|
||||
ability_dimension=ContentItem.Ability.MODELING,
|
||||
duration_seconds=360,
|
||||
is_published=True,
|
||||
)
|
||||
|
||||
first, first_reward = complete_video(user, video)
|
||||
second, second_reward = complete_video(user, video)
|
||||
|
||||
ability = UserAbility.objects.get(
|
||||
user=user,
|
||||
dimension=UserAbility.Dimension.MODELING,
|
||||
)
|
||||
assert first.completed is True
|
||||
assert second.reward_granted is True
|
||||
assert first_reward == {"ability": "modeling", "fragments": 1}
|
||||
assert second_reward is None
|
||||
assert ability.fragments == 1
|
||||
assert RewardTransaction.objects.filter(user=user).count() == 1
|
||||
assert VideoProgress.objects.filter(user=user, content=video).count() == 1
|
||||
assert ContentInteraction.objects.filter(
|
||||
user=user,
|
||||
content=video,
|
||||
action=ContentInteraction.Action.COMPLETE,
|
||||
).count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_complete_video_非视频内容拒绝完成():
|
||||
user = User.objects.create_user(
|
||||
username="knowledge_user",
|
||||
password="StrongPass_2026",
|
||||
nickname="知识用户",
|
||||
)
|
||||
content = ContentItem.objects.create(
|
||||
slug="knowledge-item",
|
||||
title="知识卡片",
|
||||
kind=ContentItem.Kind.KNOWLEDGE,
|
||||
is_published=True,
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError, match="只有视频"):
|
||||
complete_video(user, content)
|
||||
|
||||
assert RewardTransaction.objects.count() == 0
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,18 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import (
|
||||
ContentDetailView,
|
||||
ContentListView,
|
||||
FavoriteView,
|
||||
VideoCatalogView,
|
||||
VideoCompleteView,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("", ContentListView.as_view(), name="content-list"),
|
||||
path("videos/catalog/", VideoCatalogView.as_view(), name="video-catalog"),
|
||||
path("<slug:slug>/", ContentDetailView.as_view(), name="content-detail"),
|
||||
path("<slug:slug>/favorite/", FavoriteView.as_view(), name="content-favorite"),
|
||||
path("<slug:slug>/complete/", VideoCompleteView.as_view(), name="video-complete"),
|
||||
]
|
||||
@@ -0,0 +1,181 @@
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .models import ContentInteraction, ContentItem, VideoProgress
|
||||
from .services import complete_video
|
||||
|
||||
|
||||
ABILITY_META = {
|
||||
ContentItem.Ability.VISION: {"label": "数学眼光", "icon": "◉", "color": "#5d73e8"},
|
||||
ContentItem.Ability.HUMANITIES: {"label": "数学人文", "icon": "▤", "color": "#b76d38"},
|
||||
ContentItem.Ability.DETECTION: {"label": "数学侦探", "icon": "⌕", "color": "#287f78"},
|
||||
ContentItem.Ability.MODELING: {"label": "数学建模", "icon": "⌁", "color": "#d98628"},
|
||||
ContentItem.Ability.CONNECTION: {"label": "数学联结", "icon": "⛓", "color": "#8667b5"},
|
||||
}
|
||||
|
||||
|
||||
def serialize_item(item, progress=None):
|
||||
return {
|
||||
"slug": item.slug,
|
||||
"title": item.title,
|
||||
"kind": item.kind,
|
||||
"summary": item.summary,
|
||||
"body": item.body,
|
||||
"cover_url": item.cover_url,
|
||||
"media_url": item.media_url,
|
||||
"topics": item.topics,
|
||||
"source": item.source,
|
||||
"ability_dimension": item.ability_dimension,
|
||||
"ability": ABILITY_META.get(item.ability_dimension),
|
||||
"module": item.module,
|
||||
"sub_category": item.sub_category,
|
||||
"discipline": item.discipline,
|
||||
"discipline_icon": item.discipline_icon,
|
||||
"author": item.author,
|
||||
"duration_seconds": item.duration_seconds,
|
||||
"view_count": item.view_count,
|
||||
"comment_count": item.comment_count,
|
||||
"published_at": item.published_at,
|
||||
"progress": (
|
||||
{
|
||||
"position_seconds": progress.position_seconds,
|
||||
"completed": progress.completed,
|
||||
"reward_granted": progress.reward_granted,
|
||||
}
|
||||
if progress
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class ContentListView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get(self, request):
|
||||
queryset = ContentItem.objects.filter(is_published=True)
|
||||
kind = request.query_params.get("kind")
|
||||
ability = request.query_params.get("ability")
|
||||
discipline = request.query_params.get("discipline")
|
||||
if kind:
|
||||
queryset = queryset.filter(kind=kind)
|
||||
if ability:
|
||||
queryset = queryset.filter(ability_dimension=ability)
|
||||
if discipline:
|
||||
queryset = queryset.filter(discipline=discipline)
|
||||
|
||||
progress_by_content = {}
|
||||
if request.user.is_authenticated:
|
||||
progress_by_content = {
|
||||
item.content_id: item
|
||||
for item in VideoProgress.objects.filter(
|
||||
user=request.user,
|
||||
content__in=queryset,
|
||||
)
|
||||
}
|
||||
return Response(
|
||||
[
|
||||
serialize_item(item, progress_by_content.get(item.id))
|
||||
for item in queryset[:100]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class VideoCatalogView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get(self, request):
|
||||
videos = ContentItem.objects.filter(
|
||||
kind=ContentItem.Kind.VIDEO,
|
||||
is_published=True,
|
||||
)
|
||||
counts = {
|
||||
row["ability_dimension"]: row["count"]
|
||||
for row in videos.values("ability_dimension").annotate(count=Count("id"))
|
||||
}
|
||||
abilities = [
|
||||
{
|
||||
"id": ability,
|
||||
**meta,
|
||||
"count": counts.get(ability, 0),
|
||||
}
|
||||
for ability, meta in ABILITY_META.items()
|
||||
]
|
||||
disciplines = [
|
||||
{
|
||||
"name": row["discipline"],
|
||||
"icon": row["discipline_icon"],
|
||||
"count": row["count"],
|
||||
}
|
||||
for row in videos.values("discipline", "discipline_icon")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("discipline")
|
||||
]
|
||||
return Response(
|
||||
{
|
||||
"total": videos.count(),
|
||||
"abilities": abilities,
|
||||
"disciplines": disciplines,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ContentDetailView(APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get(self, request, slug):
|
||||
item = get_object_or_404(ContentItem, slug=slug, is_published=True)
|
||||
progress = None
|
||||
if request.user.is_authenticated:
|
||||
ContentInteraction.objects.get_or_create(
|
||||
user=request.user,
|
||||
content=item,
|
||||
action=ContentInteraction.Action.VIEW,
|
||||
)
|
||||
progress, _ = VideoProgress.objects.get_or_create(
|
||||
user=request.user,
|
||||
content=item,
|
||||
)
|
||||
return Response(serialize_item(item, progress))
|
||||
|
||||
|
||||
class VideoCompleteView(APIView):
|
||||
def post(self, request, slug):
|
||||
item = get_object_or_404(
|
||||
ContentItem,
|
||||
slug=slug,
|
||||
kind=ContentItem.Kind.VIDEO,
|
||||
is_published=True,
|
||||
)
|
||||
progress, reward = complete_video(request.user, item)
|
||||
return Response(
|
||||
{
|
||||
"completed": progress.completed,
|
||||
"reward_granted": progress.reward_granted,
|
||||
"reward": reward,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FavoriteView(APIView):
|
||||
def post(self, request, slug):
|
||||
item = get_object_or_404(ContentItem, slug=slug, is_published=True)
|
||||
_, created = ContentInteraction.objects.get_or_create(
|
||||
user=request.user,
|
||||
content=item,
|
||||
action=ContentInteraction.Action.FAVORITE,
|
||||
)
|
||||
return Response(
|
||||
{"favorite": True},
|
||||
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def delete(self, request, slug):
|
||||
ContentInteraction.objects.filter(
|
||||
user=request.user,
|
||||
content__slug=slug,
|
||||
action=ContentInteraction.Action.FAVORITE,
|
||||
).delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
Reference in New Issue
Block a user