v0.2 Preview: Construction Plan.
CI / test (push) Failing after 1m31s

This commit is contained in:
2026-08-08 21:25:27 +08:00
parent b88d15698a
commit 1f98f7152d
822 changed files with 96959 additions and 8 deletions
View File
+21
View File
@@ -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)
+6
View File
@@ -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'),
),
]
@@ -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'),
),
]
+85
View File
@@ -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"
)
]
+68
View File
@@ -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
+64
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+18
View File
@@ -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"),
]
+181
View File
@@ -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)