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:
@@ -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