151 lines
6.0 KiB
Python
151 lines
6.0 KiB
Python
"""Creator views & analytics."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import timedelta
|
|
|
|
from adrf.views import APIView
|
|
from django.db.models import Avg, Count, F, Q, Sum
|
|
from django.utils import timezone
|
|
from rest_framework import status
|
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
|
from rest_framework.response import Response
|
|
|
|
from apps.videos.models import Video
|
|
from core.api import opt_id, render_data
|
|
|
|
from .models import VideoPlayStat
|
|
|
|
|
|
class PlayStatReportView(APIView):
|
|
"""Client reports playback duration and completion.
|
|
|
|
Authenticated-only: anonymous reports let anyone inflate a video's play
|
|
and completion numbers, and the creator dashboard reads straight off them.
|
|
"""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
async def post(self, request):
|
|
try:
|
|
video_id = opt_id(request.data.get("video_id"))
|
|
watch_seconds = float(request.data.get("watch_seconds") or 0.0)
|
|
duration = float(request.data.get("duration") or 0.0)
|
|
except (TypeError, ValueError):
|
|
return Response({"detail": "invalid numeric field"}, status=400)
|
|
|
|
if not video_id:
|
|
return Response({"detail": "video_id required"}, status=400)
|
|
if not await Video.objects.filter(pk=video_id, status="published").aexists():
|
|
return Response({"detail": "not found"}, status=404)
|
|
|
|
# 观看进度 >= 85% 即判定为“有效完播”
|
|
is_completed = bool(duration > 0 and watch_seconds >= duration * 0.85)
|
|
|
|
await VideoPlayStat.objects.acreate(
|
|
video_id=video_id,
|
|
user=request.user,
|
|
watch_seconds=watch_seconds,
|
|
video_duration=duration,
|
|
is_completed=is_completed,
|
|
)
|
|
return Response({"status": "recorded", "is_completed": is_completed})
|
|
|
|
|
|
class CreatorDashboardView(APIView):
|
|
"""Creator analytics studio: completion rates, views, likes, trend."""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
async def get(self, request):
|
|
user = request.user
|
|
|
|
# 创作者名下视频统计
|
|
video_qs = Video.objects.filter(author=user, status="published")
|
|
total_videos = await video_qs.acount()
|
|
|
|
# 汇总基础数据:全量聚合,不再只累加最近 20 条(此前视频多于 20 个时看板偏小)。
|
|
totals = await video_qs.aaggregate(
|
|
views=Sum("view_count"),
|
|
likes=Sum("like_count"),
|
|
comments=Sum("comment_count"),
|
|
favorites=Sum("favorite_count"),
|
|
)
|
|
total_views = totals["views"] or 0
|
|
total_likes = totals["likes"] or 0
|
|
total_comments = totals["comments"] or 0
|
|
total_favorites = totals["favorites"] or 0
|
|
|
|
video_items = []
|
|
now = timezone.now()
|
|
|
|
# 完播统计一次性按 video 聚合,避免每行两次 count()。
|
|
stat_rows = {}
|
|
async for row in (
|
|
VideoPlayStat.objects.filter(video__author=user)
|
|
.values("video_id")
|
|
.annotate(total=Count("id"), completed=Count("id", filter=Q(is_completed=True)))
|
|
):
|
|
stat_rows[row["video_id"]] = row
|
|
|
|
async for v in video_qs.order_by("-created_at")[:20]:
|
|
row = stat_rows.get(v.id) or {"total": 0, "completed": 0}
|
|
stat_total = row["total"]
|
|
stat_completed = row["completed"]
|
|
|
|
completion_rate = round((stat_completed / stat_total * 100), 1) if stat_total > 0 else 0.0
|
|
|
|
# 绝对 URL:客户端(Android Coil / Web <img>)拿到相对路径无法拼接主机。
|
|
cover = v.cover_image.url if v.cover_image else ""
|
|
video_items.append({
|
|
"id": v.id,
|
|
"title": v.title,
|
|
"cover_url": request.build_absolute_uri(cover) if cover else "",
|
|
"view_count": v.view_count,
|
|
"like_count": v.like_count,
|
|
"comment_count": v.comment_count,
|
|
"favorite_count": v.favorite_count,
|
|
"play_count": stat_total,
|
|
"completed_count": stat_completed,
|
|
"completion_rate": completion_rate,
|
|
"created_at": v.created_at.strftime("%Y-%m-%d"),
|
|
})
|
|
|
|
# 创作者平均完播率
|
|
all_stats_qs = VideoPlayStat.objects.filter(video__author=user)
|
|
grand_plays = await all_stats_qs.acount()
|
|
grand_completed = await all_stats_qs.filter(is_completed=True).acount()
|
|
avg_completion_rate = round((grand_completed / grand_plays * 100), 1) if grand_plays > 0 else 0.0
|
|
|
|
# 近7日每日播放量趋势模拟与真实统计
|
|
daily_trends = []
|
|
for i in range(6, -1, -1):
|
|
day_start = (now - timedelta(days=i)).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
day_end = day_start + timedelta(days=1)
|
|
count = await all_stats_qs.filter(created_at__gte=day_start, created_at__lt=day_end).acount()
|
|
daily_trends.append({
|
|
"date": day_start.strftime("%m-%d"),
|
|
"plays": count,
|
|
})
|
|
|
|
# 智能创作诊断建议
|
|
advice = "继续保持创作!高完播率的作品前3秒黄金节奏至关重要。"
|
|
if avg_completion_rate >= 60.0:
|
|
advice = "太棒了!您的作品平均完播率达到优秀水平(≥60%),系统算法推荐加权中!"
|
|
elif total_videos > 0 and avg_completion_rate < 30.0:
|
|
advice = "提示:平均完播率偏低,建议优化视频前3秒悬念、背景音乐节奏与中段反转。"
|
|
|
|
return Response({
|
|
"overview": {
|
|
"total_videos": total_videos,
|
|
"total_views": total_views,
|
|
"total_likes": total_likes,
|
|
"total_comments": total_comments,
|
|
"total_favorites": total_favorites,
|
|
"follower_count": user.follower_count,
|
|
"avg_completion_rate": avg_completion_rate,
|
|
},
|
|
"daily_trends": daily_trends,
|
|
"advice": advice,
|
|
"videos": video_items,
|
|
})
|