feat(core/backend): pipeline continuity + threaded ffmpeg burn-in export + upload/save-timeline
Video pipeline (script→assets→storyboard→video→stitch): - robust split_script_into_segments (4 non-empty scenes), scene-aware storyboard/video prompts - link VideoSegment→ScriptSegment + storyboard-frame reference image (graceful text fallback) - idempotent poll_video_segment (no double-charge on repeated polling) - threaded export (no Celery worker needed) + poll-export endpoint - run_export_job rewritten to filter_complex: per-clip trim, xfade transitions, subtitle burn-in (Pillow PNG overlay; this ffmpeg lacks libass), BGM mix - upload-video-segment / upload-bgm / save-timeline endpoints - serializers embed asset preview URLs (beat assets pagination); Pillow added to requirements Also includes prior uncommitted backend work: account preferences/sessions, billing trend, product/asset endpoints, accounts 0002 migration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
# Generated by Django 5.1.15 on 2026-06-08 09:48
|
||||
|
||||
import apps.accounts.models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="LoginSession",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("user_agent", models.CharField(blank=True, max_length=400)),
|
||||
("ip_address", models.GenericIPAddressField(blank=True, null=True)),
|
||||
("last_seen_at", models.DateTimeField(auto_now=True)),
|
||||
("revoked_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="login_sessions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-last_seen_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserPreference",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"notify",
|
||||
models.JSONField(
|
||||
blank=True, default=apps.accounts.models._default_notify
|
||||
),
|
||||
),
|
||||
("two_factor_enabled", models.BooleanField(default=False)),
|
||||
(
|
||||
"creation_defaults",
|
||||
models.JSONField(
|
||||
blank=True, default=apps.accounts.models._default_creation
|
||||
),
|
||||
),
|
||||
(
|
||||
"display",
|
||||
models.JSONField(
|
||||
blank=True, default=apps.accounts.models._default_display
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="preference",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -34,6 +34,44 @@ class Team(TimeStampedModel):
|
||||
return self.name
|
||||
|
||||
|
||||
def _default_notify() -> dict:
|
||||
return {"n-export": True, "n-fail": True, "n-quota": True, "n-login": True}
|
||||
|
||||
|
||||
def _default_creation() -> dict:
|
||||
return {"template": "pain", "duration": "60", "subtitle": "big-variety", "bgm": "kapian", "transition": "fade"}
|
||||
|
||||
|
||||
def _default_display() -> dict:
|
||||
return {"appearance": "system", "language": "zh", "density": "standard"}
|
||||
|
||||
|
||||
class UserPreference(TimeStampedModel):
|
||||
"""用户设置:通知策略 / 两步验证 / 创作默认 / 显示偏好。服务端持久化(替代前端 localStorage)。"""
|
||||
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="preference")
|
||||
notify = models.JSONField(default=_default_notify, blank=True)
|
||||
two_factor_enabled = models.BooleanField(default=False)
|
||||
creation_defaults = models.JSONField(default=_default_creation, blank=True)
|
||||
display = models.JSONField(default=_default_display, blank=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"prefs/{self.user}"
|
||||
|
||||
|
||||
class LoginSession(TimeStampedModel):
|
||||
"""登录会话记录:每次登录写一条(设备 UA / IP / 时间),供设置页「在用设备」展示与下线。"""
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="login_sessions")
|
||||
user_agent = models.CharField(max_length=400, blank=True)
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
last_seen_at = models.DateTimeField(auto_now=True)
|
||||
revoked_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-last_seen_at"]
|
||||
|
||||
|
||||
class TeamMember(TimeStampedModel):
|
||||
class Role(models.TextChoices):
|
||||
OWNER = "owner", "Owner"
|
||||
|
||||
@@ -2,7 +2,7 @@ from rest_framework import serializers
|
||||
|
||||
from apps.billing.models import CreditAccount
|
||||
|
||||
from .models import Team, TeamMember, User
|
||||
from .models import LoginSession, Team, TeamMember, User, UserPreference
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
@@ -12,6 +12,26 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = ["id", "status"]
|
||||
|
||||
|
||||
class UserPreferenceSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = UserPreference
|
||||
fields = ["notify", "two_factor_enabled", "creation_defaults", "display", "updated_at"]
|
||||
read_only_fields = ["updated_at"]
|
||||
|
||||
|
||||
class LoginSessionSerializer(serializers.ModelSerializer):
|
||||
is_current = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = LoginSession
|
||||
fields = ["id", "user_agent", "ip_address", "last_seen_at", "created_at", "is_current"]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_is_current(self, obj) -> bool:
|
||||
ctx = self.context or {}
|
||||
return bool(obj.ip_address and obj.ip_address == ctx.get("current_ip") and obj.user_agent == ctx.get("current_ua"))
|
||||
|
||||
|
||||
class TeamSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Team
|
||||
|
||||
@@ -3,9 +3,13 @@ from django.urls import path
|
||||
from .views import (
|
||||
change_password,
|
||||
login,
|
||||
login_sessions,
|
||||
logout,
|
||||
me,
|
||||
preferences,
|
||||
register,
|
||||
revoke_login_session,
|
||||
revoke_other_sessions,
|
||||
team_member_detail,
|
||||
team_member_password,
|
||||
team_members,
|
||||
@@ -20,6 +24,10 @@ urlpatterns = [
|
||||
path("me/", me, name="auth-me"),
|
||||
path("me/password/", change_password, name="auth-change-password"),
|
||||
path("me/avatar/", update_avatar, name="auth-avatar"),
|
||||
path("me/preferences/", preferences, name="auth-preferences"),
|
||||
path("me/sessions/", login_sessions, name="auth-sessions"),
|
||||
path("me/sessions/revoke-others/", revoke_other_sessions, name="auth-sessions-revoke-others"),
|
||||
path("me/sessions/<uuid:session_id>/revoke/", revoke_login_session, name="auth-session-revoke"),
|
||||
path("team/members/", team_members, name="team-members"),
|
||||
path("team/members/<uuid:member_id>/", team_member_detail, name="team-member-detail"),
|
||||
path("team/members/<uuid:member_id>/password/", team_member_password, name="team-member-password"),
|
||||
|
||||
@@ -12,8 +12,16 @@ from rest_framework.response import Response
|
||||
|
||||
from apps.common.api import get_current_team
|
||||
|
||||
from .models import TeamMember, User
|
||||
from .serializers import LoginSerializer, RegisterSerializer, TeamMemberSerializer, TeamSerializer, UserSerializer
|
||||
from .models import LoginSession, TeamMember, User, UserPreference
|
||||
from .serializers import (
|
||||
LoginSerializer,
|
||||
LoginSessionSerializer,
|
||||
RegisterSerializer,
|
||||
TeamMemberSerializer,
|
||||
TeamSerializer,
|
||||
UserPreferenceSerializer,
|
||||
UserSerializer,
|
||||
)
|
||||
|
||||
|
||||
def auth_payload(user, team, token):
|
||||
@@ -24,6 +32,25 @@ def auth_payload(user, team, token):
|
||||
}
|
||||
|
||||
|
||||
def _client_ip(request):
|
||||
forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.META.get("REMOTE_ADDR") or None
|
||||
|
||||
|
||||
def record_login_session(request, user):
|
||||
"""登录成功后记录一条会话(设备 UA / IP),供设置页「在用设备」展示。"""
|
||||
try:
|
||||
LoginSession.objects.create(
|
||||
user=user,
|
||||
user_agent=(request.META.get("HTTP_USER_AGENT") or "")[:400],
|
||||
ip_address=_client_ip(request),
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 会话记录失败不应阻断登录
|
||||
pass
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([])
|
||||
def register(request):
|
||||
@@ -31,6 +58,7 @@ def register(request):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.save()
|
||||
token, _ = Token.objects.get_or_create(user=data["user"])
|
||||
record_login_session(request, data["user"])
|
||||
return Response(auth_payload(data["user"], data["team"], token), status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@@ -48,6 +76,7 @@ def login(request):
|
||||
return Response({"detail": "invalid credentials"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
team = get_current_team(user)
|
||||
token, _ = Token.objects.get_or_create(user=user)
|
||||
record_login_session(request, user)
|
||||
return Response(auth_payload(user, team, token))
|
||||
|
||||
|
||||
@@ -97,12 +126,19 @@ def change_password(request):
|
||||
return Response({"token": token.key})
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@api_view(["POST", "DELETE"])
|
||||
@parser_classes([MultiPartParser, FormParser])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def update_avatar(request):
|
||||
from apps.assets.storage import TosStorage
|
||||
|
||||
# DELETE = 恢复默认头像(清空 avatar_url,前端回退到首字母占位)
|
||||
if request.method == "DELETE":
|
||||
user = request.user
|
||||
user.avatar_url = ""
|
||||
user.save(update_fields=["avatar_url"])
|
||||
return Response(UserSerializer(user).data)
|
||||
|
||||
upload = request.FILES.get("file")
|
||||
if upload is None:
|
||||
return Response({"detail": "no file"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -223,3 +259,52 @@ def team_member_password(request, member_id):
|
||||
member.user.save(update_fields=["password"])
|
||||
Token.objects.filter(user=member.user).delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@api_view(["GET", "PUT", "PATCH"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def preferences(request):
|
||||
"""用户设置:通知策略 / 两步验证 / 创作默认 / 显示偏好。服务端持久化。"""
|
||||
pref, _ = UserPreference.objects.get_or_create(user=request.user)
|
||||
if request.method in ("PUT", "PATCH"):
|
||||
serializer = UserPreferenceSerializer(pref, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
pref.refresh_from_db()
|
||||
return Response(UserPreferenceSerializer(pref).data)
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def login_sessions(request):
|
||||
"""在用设备:返回未下线的登录会话(最近 20 条)。"""
|
||||
sessions = LoginSession.objects.filter(user=request.user, revoked_at__isnull=True)[:20]
|
||||
current_ip = _client_ip(request)
|
||||
current_ua = (request.META.get("HTTP_USER_AGENT") or "")[:400]
|
||||
data = LoginSessionSerializer(sessions, many=True, context={"current_ip": current_ip, "current_ua": current_ua}).data
|
||||
return Response(data)
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def revoke_login_session(request, session_id):
|
||||
"""下线单个设备会话。"""
|
||||
from django.utils import timezone
|
||||
|
||||
updated = LoginSession.objects.filter(user=request.user, id=session_id, revoked_at__isnull=True).update(
|
||||
revoked_at=timezone.now()
|
||||
)
|
||||
return Response({"revoked": updated})
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def revoke_other_sessions(request):
|
||||
"""下线除当前外的所有其他设备:旋转 token(令其他端 token 失效)+ 标记会话已下线。"""
|
||||
from django.utils import timezone
|
||||
|
||||
LoginSession.objects.filter(user=request.user, revoked_at__isnull=True).update(revoked_at=timezone.now())
|
||||
Token.objects.filter(user=request.user).delete()
|
||||
token, _ = Token.objects.get_or_create(user=request.user)
|
||||
record_login_session(request, request.user)
|
||||
return Response({"token": token.key})
|
||||
|
||||
Reference in New Issue
Block a user