diff --git a/alembic/versions/005_add_generated_video_management_fields.py b/alembic/versions/005_add_generated_video_management_fields.py new file mode 100644 index 000000000..a3ba21d3f --- /dev/null +++ b/alembic/versions/005_add_generated_video_management_fields.py @@ -0,0 +1,34 @@ +"""Add generated video management fields. + +Revision ID: 005 +Revises: 004 +Create Date: 2026-06-24 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "005" +down_revision: Union[str, None] = "004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("generated_videos", sa.Column("status", sa.String(length=20), nullable=False, server_default="completed")) + op.add_column("generated_videos", sa.Column("review_status", sa.String(length=20), nullable=False, server_default="pending_review")) + op.add_column("generated_videos", sa.Column("generation_params", sa.Text(), nullable=False, server_default="{}")) + op.add_column("generated_videos", sa.Column("updated_at", sa.DateTime(), nullable=True)) + op.create_index(op.f("ix_generated_videos_status"), "generated_videos", ["status"], unique=False) + op.create_index(op.f("ix_generated_videos_review_status"), "generated_videos", ["review_status"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_generated_videos_review_status"), table_name="generated_videos") + op.drop_index(op.f("ix_generated_videos_status"), table_name="generated_videos") + op.drop_column("generated_videos", "updated_at") + op.drop_column("generated_videos", "generation_params") + op.drop_column("generated_videos", "review_status") + op.drop_column("generated_videos", "status") diff --git a/apps/api/app/api/routes/generated_videos.py b/apps/api/app/api/routes/generated_videos.py index 876140210..f85489a90 100644 --- a/apps/api/app/api/routes/generated_videos.py +++ b/apps/api/app/api/routes/generated_videos.py @@ -8,6 +8,7 @@ from app.schemas.generated_video import ( GeneratedVideoDownloadUrlResponse, GeneratedVideoResponse, ListGeneratedVideosResponse, + UpdateGeneratedVideoReviewRequest, ) from fastapi import APIRouter, Depends, HTTPException @@ -35,6 +36,9 @@ def _to_generated_video_response(item) -> GeneratedVideoResponse: width=item.width, height=item.height, fps=item.fps, + status=item.status, + review_status=item.review_status, + generation_params=item.generation_params, ) @@ -70,6 +74,23 @@ def get_generated_video( return _to_generated_video_response(item) +@router.patch("/{video_id}/review", response_model=GeneratedVideoResponse) +def update_generated_video_review_status( + video_id: str, + request: UpdateGeneratedVideoReviewRequest, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + generated_video_repository: Any = Depends(get_generated_video_repository), + workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), +) -> GeneratedVideoResponse: + video = generated_video_repository.get(video_id) + if video is None: + raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found") + require_workspace_member(video.workspace_id, authenticated_user, workspace_member_repository) + video.review_status = request.review_status + updated = generated_video_repository.update(video) + return _to_generated_video_response(updated) + + @router.get("/{video_id}/download-url", response_model=GeneratedVideoDownloadUrlResponse) def get_generated_video_download_url( video_id: str, diff --git a/apps/api/app/schemas/generated_video.py b/apps/api/app/schemas/generated_video.py index c09f7fdc5..c44e57d33 100644 --- a/apps/api/app/schemas/generated_video.py +++ b/apps/api/app/schemas/generated_video.py @@ -1,4 +1,12 @@ -from pydantic import BaseModel +from typing import Literal + +from pydantic import BaseModel, Field + +GeneratedVideoReviewStatus = Literal["pending_review", "approved", "rejected"] + + +class UpdateGeneratedVideoReviewRequest(BaseModel): + review_status: GeneratedVideoReviewStatus class GeneratedVideoResponse(BaseModel): @@ -14,6 +22,9 @@ class GeneratedVideoResponse(BaseModel): width: int height: int fps: float + status: str = "completed" + review_status: str = "pending_review" + generation_params: dict = Field(default_factory=dict) class GeneratedVideoDownloadUrlResponse(BaseModel): diff --git a/apps/web/e2e/core-generation.spec.ts b/apps/web/e2e/core-generation.spec.ts index 6943e9d55..be0f1272f 100644 --- a/apps/web/e2e/core-generation.spec.ts +++ b/apps/web/e2e/core-generation.spec.ts @@ -154,6 +154,15 @@ test.describe('Core generation and download flow', () => { await page.goto(`/projects/${projectData.id}/results`); await expect(page.getByRole('heading').getByText(generatedVideo.name, { exact: true })).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText('待复核')).toBeVisible({ timeout: 20_000 }); + await page.getByRole('button', { name: '标记可发布' }).click(); + await expect(page.getByText('成片复核状态已更新')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText('可发布')).toBeVisible({ timeout: 20_000 }); + const reviewedVideo = await request.get(`${apiBase}/generated-videos/${generatedVideo.id}`, { headers }); + expect(reviewedVideo.status(), await reviewedVideo.text()).toBe(200); + const reviewedVideoData = (await reviewedVideo.json()) as { review_status: string; generation_params: Record }; + expect(reviewedVideoData.review_status).toBe('approved'); + expect(reviewedVideoData.generation_params.title_id).toBe(titleData.id); const downloadUrlResponse = await request.get(`${apiBase}/generated-videos/${generatedVideo.id}/download-url`, { headers }); expect(downloadUrlResponse.status(), await downloadUrlResponse.text()).toBe(200); const downloadData = (await downloadUrlResponse.json()) as { download_url: string }; diff --git a/apps/web/src/api/generation.ts b/apps/web/src/api/generation.ts index cf65e30db..7856deaeb 100644 --- a/apps/web/src/api/generation.ts +++ b/apps/web/src/api/generation.ts @@ -26,6 +26,9 @@ export interface GeneratedVideoItem { width: number; height: number; fps: number; + status?: string; + review_status?: 'pending_review' | 'approved' | 'rejected'; + generation_params?: Record; } export const createGenerationTask = async (data: { @@ -55,6 +58,11 @@ export const getGeneratedVideos = async (projectId: string): Promise => { + const response = await apiClient.patch(`/generated-videos/${videoId}/review`, { review_status: reviewStatus }); + return response.data; +}; + export const getGeneratedVideoDownloadUrl = async (videoId: string): Promise => { const response = await apiClient.get(`/generated-videos/${videoId}/download-url`); return response.data.download_url; diff --git a/apps/web/src/pages/workspace/ProjectResults.tsx b/apps/web/src/pages/workspace/ProjectResults.tsx index 753b127e8..84e2ccbde 100644 --- a/apps/web/src/pages/workspace/ProjectResults.tsx +++ b/apps/web/src/pages/workspace/ProjectResults.tsx @@ -1,14 +1,21 @@ import React, { useEffect } from 'react'; import { useLocation, useParams } from 'react-router-dom'; import { Alert, Button, Card, Empty, List, Space, Spin, Tag, Typography, message } from 'antd'; -import { useQuery } from '@tanstack/react-query'; -import { getGeneratedVideoDownloadUrl, getGeneratedVideos } from '@/api/generation'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { getGeneratedVideoDownloadUrl, getGeneratedVideos, updateGeneratedVideoReviewStatus } from '@/api/generation'; + +const reviewLabels: Record = { + pending_review: { label: '待复核', color: 'gold' }, + approved: { label: '可发布', color: 'green' }, + rejected: { label: '需返工', color: 'red' }, +}; const ProjectResults: React.FC = () => { const { id } = useParams<{ id: string }>(); const location = useLocation(); const projectId = id || ''; const routedWorkspaceId = (location.state as { workspaceId?: string } | null)?.workspaceId || ''; + const queryClient = useQueryClient(); useEffect(() => { if (projectId && routedWorkspaceId) { @@ -23,6 +30,16 @@ const ProjectResults: React.FC = () => { refetchInterval: 5000, }); + const reviewMutation = useMutation({ + mutationFn: ({ videoId, reviewStatus }: { videoId: string; reviewStatus: 'approved' | 'rejected' }) => + updateGeneratedVideoReviewStatus(videoId, reviewStatus), + onSuccess: () => { + message.success('成片复核状态已更新'); + queryClient.invalidateQueries({ queryKey: ['generated-videos', projectId] }); + }, + onError: () => message.error('成片复核状态更新失败'), + }); + const handleDownload = async (videoId: string) => { try { const url = await getGeneratedVideoDownloadUrl(videoId); @@ -34,7 +51,7 @@ const ProjectResults: React.FC = () => { return (
- + {videosQuery.isError && ( )} @@ -44,31 +61,47 @@ const ProjectResults: React.FC = () => { ) : videosQuery.data?.length ? ( ( - handleDownload(item.id)}> - 获取下载地址 - , - ]} - > - - {item.name} - {item.width}x{item.height} - {item.duration}s - {Math.round(item.file_size / 1024)} KB - - } - description={ - - {item.name} - - } - /> - - )} + renderItem={(item) => { + const review = reviewLabels[item.review_status || 'pending_review'] || reviewLabels.pending_review; + return ( + handleDownload(item.id)}> + 获取下载地址 + , + , + , + ]} + > + + {item.name} + {item.status || 'completed'} + {review.label} + {item.width}x{item.height} + {item.duration}s + {Math.round(item.file_size / 1024)} KB + + } + description={ + + + {item.name} + + + 生成参数:素材库 {String(item.generation_params?.asset_library_id || '未记录')},标题 {String(item.generation_params?.title_id || '自动/未选择')} + + + } + /> + + ); + }} /> ) : ( diff --git a/apps/worker/worker_app/tasks/generation.py b/apps/worker/worker_app/tasks/generation.py index 24c05bf20..0c47a3947 100644 --- a/apps/worker/worker_app/tasks/generation.py +++ b/apps/worker/worker_app/tasks/generation.py @@ -241,6 +241,15 @@ def generate_video(task_id: str) -> dict: height=OUTPUT_HEIGHT, fps=OUTPUT_FPS, thumbnail_url=None, + generation_params={ + "asset_library_id": task.asset_library_id, + "voice_library_id": task.voice_library_id, + "title_id": task.strategy_id, + "output_width": OUTPUT_WIDTH, + "output_height": OUTPUT_HEIGHT, + "output_fps": OUTPUT_FPS, + "output_duration_seconds": OUTPUT_DURATION_SECONDS, + }, ) video_repo.create(video) diff --git a/packages/adapters/sqlalchemy_impl/generated_video_repository.py b/packages/adapters/sqlalchemy_impl/generated_video_repository.py index 376da70bd..bfe1056ab 100644 --- a/packages/adapters/sqlalchemy_impl/generated_video_repository.py +++ b/packages/adapters/sqlalchemy_impl/generated_video_repository.py @@ -1,3 +1,5 @@ +import json + from sqlalchemy.orm import Session from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel @@ -22,6 +24,9 @@ class SQLAlchemyGeneratedVideoRepository: width=video.width, height=video.height, fps=video.fps, + status=video.status, + review_status=video.review_status, + generation_params=json.dumps(video.generation_params, ensure_ascii=False), generated_at=video.generated_at, created_at=video.created_at, ) @@ -46,10 +51,24 @@ class SQLAlchemyGeneratedVideoRepository: width=int(model.width or 0), height=int(model.height or 0), fps=model.fps, + status=getattr(model, "status", "completed"), + review_status=getattr(model, "review_status", "pending_review"), + generation_params=json.loads(getattr(model, "generation_params", "{}") or "{}"), generated_at=model.generated_at, created_at=model.created_at, ) + def update(self, video: GeneratedVideo) -> GeneratedVideo: + model = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video.id).first() + if model is None: + return video + model.status = video.status + model.review_status = video.review_status + model.generation_params = json.dumps(video.generation_params, ensure_ascii=False) + self.session.add(model) + self.session.commit() + return video + def list_by_project(self, project_id: str) -> list[GeneratedVideo]: models = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.project_id == project_id).all() return [self.get(model.id) for model in models if self.get(model.id) is not None] diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index f98e3c5f6..89b4cfee5 100644 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -195,8 +195,12 @@ class GeneratedVideoModel(Base): width = Column(Float, nullable=False) height = Column(Float, nullable=False) fps = Column(Float, nullable=False) + status = Column(String(20), nullable=False, default="completed", index=True) + review_status = Column(String(20), nullable=False, default="pending_review", index=True) + generation_params = Column(Text, nullable=False, default="{}") generated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, nullable=True) class TaskModel(Base): diff --git a/packages/domain/generated_video.py b/packages/domain/generated_video.py index 68f28ad0d..aa807158d 100644 --- a/packages/domain/generated_video.py +++ b/packages/domain/generated_video.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime, timezone +from typing import Any from uuid import uuid4 @@ -19,6 +20,9 @@ class GeneratedVideo: height: int fps: float thumbnail_url: str | None = None + status: str = "completed" + review_status: str = "pending_review" + generation_params: dict[str, Any] = field(default_factory=dict) generated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) @@ -37,6 +41,7 @@ class GeneratedVideo: height: int, fps: float, thumbnail_url: str | None = None, + generation_params: dict[str, Any] | None = None, ) -> "GeneratedVideo": if not workspace_id.strip(): raise ValueError("workspace_id 不能为空") @@ -61,4 +66,5 @@ class GeneratedVideo: height=height, fps=fps, thumbnail_url=thumbnail_url, + generation_params=generation_params or {}, ) diff --git a/tests/unit/test_generated_video_management.py b/tests/unit/test_generated_video_management.py new file mode 100644 index 000000000..acc00c67d --- /dev/null +++ b/tests/unit/test_generated_video_management.py @@ -0,0 +1,47 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from packages.adapters.sqlalchemy_impl.generated_video_repository import SQLAlchemyGeneratedVideoRepository +from packages.adapters.sqlalchemy_impl.models import Base +from packages.domain import GeneratedVideo + + +def _repository(): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + session = sessionmaker(bind=engine)() + return SQLAlchemyGeneratedVideoRepository(session) + + +def test_generated_video_repository_preserves_management_fields(): + repository = _repository() + video = GeneratedVideo.create( + workspace_id="workspace-1", + project_id="project-1", + generation_task_id="task-1", + name="output.mp4", + file_url="generated/output.mp4", + file_size=2048, + duration=5.0, + width=1280, + height=720, + fps=25.0, + generation_params={"asset_library_id": "library-1", "title_id": "title-1"}, + ) + + repository.create(video) + saved = repository.get(video.id) + + assert saved.status == "completed" + assert saved.review_status == "pending_review" + assert saved.generation_params["title_id"] == "title-1" + + saved.review_status = "approved" + repository.update(saved) + + assert repository.get(video.id).review_status == "approved"