Merge pull request 'chore: develop → main 同步 v0.1.99' (#116) from develop into main
Auto Merge PRs / auto-merge (push) Failing after 33s
Deploy / Deploy Staging (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Build Production Runtime Images (push) Failing after 231h20m54s

This commit was merged in pull request #116.
This commit is contained in:
2026-06-29 21:32:49 +08:00
22 changed files with 708 additions and 479 deletions
@@ -0,0 +1,34 @@
"""add generation task extensions
Revision ID: 015
Revises: 014
Create Date: 2026-06-29
"""
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
from alembic import op
# revision identifiers
revision = "015"
down_revision = "014"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("generation_tasks", sa.Column("template_id", sa.String(36), nullable=False, server_default=""))
op.add_column("generation_tasks", sa.Column("asset_ids", mysql.JSON(), nullable=False, server_default="[]"))
op.add_column("generation_tasks", sa.Column("title_ids", mysql.JSON(), nullable=False, server_default="[]"))
op.add_column("generation_tasks", sa.Column("voice_ids", mysql.JSON(), nullable=False, server_default="[]"))
op.create_index(op.f("ix_generation_tasks_template_id"), "generation_tasks", ["template_id"])
def downgrade() -> None:
op.drop_index(op.f("ix_generation_tasks_template_id"), table_name="generation_tasks")
op.drop_column("generation_tasks", "voice_ids")
op.drop_column("generation_tasks", "title_ids")
op.drop_column("generation_tasks", "asset_ids")
op.drop_column("generation_tasks", "template_id")
+6
View File
@@ -1,3 +1,4 @@
from app.api.routes.dashboard import router as dashboard_router
from app.api.routes.asset_diagnosis import router as asset_diagnosis_router
from app.api.routes.asset_libraries import router as asset_libraries_router
from app.api.routes.assets import router as assets_router
@@ -110,3 +111,8 @@ api_router.include_router(
prefix="/templates",
tags=["Template"],
)
api_router.include_router(
dashboard_router,
prefix="/dashboard",
tags=["Dashboard"],
)
+92
View File
@@ -0,0 +1,92 @@
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import (
get_asset_repository,
get_generation_task_repository,
get_project_repository,
get_title_library_repository,
get_voice_library_repository,
)
from app.schemas.dashboard import DashboardOverviewResponse, RecentTaskItem, SubscriptionInfo
from fastapi import APIRouter, Depends
router = APIRouter()
def _status_value(status) -> str:
return status.value if hasattr(status, "value") else str(status)
def _generation_step(status: str) -> str:
if status == "pending":
return "等待 Worker 执行"
if status == "running":
return "正在生成成片"
if status == "completed":
return "生成完成"
if status == "failed":
return "生成失败"
return status
@router.get("/overview", response_model=DashboardOverviewResponse)
def get_dashboard_overview(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_repository: Any = Depends(get_asset_repository),
generation_task_repository: Any = Depends(get_generation_task_repository),
title_library_repository: Any = Depends(get_title_library_repository),
voice_library_repository: Any = Depends(get_voice_library_repository),
) -> DashboardOverviewResponse:
"""Dashboard 概览:用户级汇总数据。"""
user_id = authenticated_user.user.id
# 获取用户可访问的所有 project
projects = project_repository.find_accessible_projects(user_id)
project_ids = [p.id for p in projects]
# 素材统计
total_assets = asset_repository.count_by_project_ids(project_ids)
used_storage_bytes = asset_repository.sum_storage_by_project_ids(project_ids)
# 标题库 / 配音库统计
total_titles = title_library_repository.count_by_user(user_id)
total_voices = voice_library_repository.count_by_user(user_id)
# 生成任务统计
total_tasks = generation_task_repository.count_by_user(user_id)
# 最近任务(SQL 层 LIMIT 5
recent = generation_task_repository.list_recent_by_user(user_id, limit=5)
recent_tasks = []
for task in recent:
s = _status_value(task.status)
recent_tasks.append(
RecentTaskItem(
id=task.id,
task_type="generation",
status=s,
current_step=_generation_step(s),
error_message=task.error_message or "",
updated_at=task.completed_at or task.started_at or task.created_at,
)
)
# 订阅信息
user = authenticated_user.user
subscription = SubscriptionInfo(
plan=getattr(user, "subscription_plan", "free") or "free",
is_active=getattr(user, "subscription_status", "") == "active",
)
return DashboardOverviewResponse(
total_assets=total_assets,
used_storage_bytes=used_storage_bytes,
total_titles=total_titles,
total_voices=total_voices,
total_tasks=total_tasks,
total_products=len(projects),
subscription=subscription,
recent_tasks=recent_tasks,
)
+109 -16
View File
@@ -16,6 +16,7 @@ from app.schemas.generated_video import (
from app.schemas.generation_task import (
CreateGenerationTaskRequest,
GenerationTaskResponse,
ListGenerationTasksResponse,
)
from fastapi import APIRouter, Depends, HTTPException
@@ -45,6 +46,10 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
asset_library_id=task.asset_library_id,
strategy_id=task.strategy_id,
voice_library_id=task.voice_library_id,
template_id=task.template_id,
asset_ids=task.asset_ids,
title_ids=task.title_ids,
voice_ids=task.voice_ids,
status=task.status,
progress=task.progress,
result_count=task.result_count,
@@ -79,6 +84,43 @@ def _ensure_library_has_ready_video_assets(assets) -> None:
)
def _resolve_project_and_library(
request: CreateGenerationTaskRequest,
project_repository: Any,
asset_library_repository: Any,
asset_repository: Any,
authenticated_user: AuthenticatedUser,
) -> tuple[str, str]:
"""解析 project_id 和 asset_library_id。
支持两种模式:
- 显式传入(向后兼容)
- 从 asset_ids 反查 asset_library(模板模式)
返回 (project_id, asset_library_id)。
"""
project_id = request.project_id.strip()
asset_library_id = request.asset_library_id.strip()
# 模板模式:project_id 未提供时,从 asset_ids 反查所属 project
if not project_id and request.asset_ids:
first_asset_id = request.asset_ids[0]
asset = asset_repository.find_by_id(first_asset_id)
if asset is not None:
project_id = asset.project_id
if not asset_library_id:
asset_library_id = asset.library_id
# 向后兼容校验:project_id 已提供时验证权限
if project_id:
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
if not project.can_access(authenticated_user.user.id):
raise HTTPException(status_code=403, detail="Access denied to project")
return project_id, asset_library_id
@router.post("/tasks", response_model=GenerationTaskResponse)
def create_generation_task(
request: CreateGenerationTaskRequest,
@@ -88,26 +130,30 @@ def create_generation_task(
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
) -> GenerationTaskResponse:
project = project_repository.find_by_id(request.project_id)
if project is None:
raise HTTPException(status_code=404, detail=f"Project {request.project_id} not found")
if not project.can_access(authenticated_user.user.id):
raise HTTPException(status_code=403, detail="Access denied to project")
library = asset_library_repository.get(request.asset_library_id)
if library is None or library.project_id != request.project_id:
raise HTTPException(status_code=404, detail=f"AssetLibrary {request.asset_library_id} not found")
assets = asset_repository.list_by_library(request.asset_library_id)
_ensure_library_has_ready_video_assets(assets)
project_id, asset_library_id = _resolve_project_and_library(
request, project_repository, asset_library_repository, asset_repository, authenticated_user
)
# asset_library 存在性校验(仅在提供了 asset_library_id 时)
if asset_library_id:
library = asset_library_repository.get(asset_library_id)
if library is None or (project_id and library.project_id != project_id):
raise HTTPException(status_code=404, detail=f"AssetLibrary {asset_library_id} not found")
assets = asset_repository.find_by_library(asset_library_id)
_ensure_library_has_ready_video_assets(assets)
use_case = CreateGenerationTaskUseCase(generation_task_repository)
task = use_case.execute(
CreateGenerationTaskCommand(
project_id=request.project_id,
asset_library_id=request.asset_library_id,
project_id=project_id,
asset_library_id=asset_library_id,
strategy_id=request.strategy_id,
voice_library_id=request.voice_library_id,
template_id=request.template_id,
asset_ids=request.asset_ids,
title_ids=request.title_ids,
voice_ids=request.voice_ids,
created_by_user_id=authenticated_user.user.id,
)
)
@@ -115,6 +161,17 @@ def create_generation_task(
return _to_generation_task_response(task)
@router.get("/tasks", response_model=ListGenerationTasksResponse)
def list_generation_tasks(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository),
) -> ListGenerationTasksResponse:
"""用户级生成任务列表(跨 project)。"""
tasks = generation_task_repository.list_by_user(authenticated_user.user.id)
items = [_to_generation_task_response(task) for task in tasks]
return ListGenerationTasksResponse(items=items)
@router.get("/tasks/{task_id}", response_model=GenerationTaskResponse)
def get_generation_task(
task_id: str,
@@ -126,7 +183,8 @@ def get_generation_task(
task = use_case.execute(task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
_check_project_access(task.project_id, authenticated_user.user.id, project_repository)
if task.project_id:
_check_project_access(task.project_id, authenticated_user.user.id, project_repository)
return _to_generation_task_response(task)
@@ -141,7 +199,42 @@ def list_generation_results(
task = generation_task_repository.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
_check_project_access(task.project_id, authenticated_user.user.id, project_repository)
if task.project_id:
_check_project_access(task.project_id, authenticated_user.user.id, project_repository)
use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
items = use_case.execute(task_id)
return ListGeneratedVideosResponse(items=[_to_generated_video_response(item) for item in items])
@router.post("/tasks/{task_id}/retry", response_model=GenerationTaskResponse)
def retry_generation_task(
task_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository),
) -> GenerationTaskResponse:
"""简化重试:通过 task_id 直接重试失败任务。"""
task = generation_task_repository.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Generation task not found")
if task.created_by_user_id and task.created_by_user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="Access denied to this task")
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
if status_val != "failed":
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
use_case = CreateGenerationTaskUseCase(generation_task_repository)
retried = use_case.execute(
CreateGenerationTaskCommand(
project_id=task.project_id,
asset_library_id=task.asset_library_id,
strategy_id=task.strategy_id,
voice_library_id=task.voice_library_id,
template_id=task.template_id,
asset_ids=task.asset_ids,
title_ids=task.title_ids,
voice_ids=task.voice_ids,
created_by_user_id=authenticated_user.user.id,
)
)
celery_app.send_task("worker.generate_video", args=[retried.id])
return _to_generation_task_response(retried)
+2
View File
@@ -22,8 +22,10 @@ router = APIRouter()
def _to_project_response(item) -> ProjectResponse:
return ProjectResponse(
id=item.id,
owner_user_id=item.owner_user_id,
name=item.name,
description=item.description,
shared_users=item.shared_users,
)
+136 -45
View File
@@ -7,7 +7,12 @@ from app.dependencies import (
get_ingest_job_repository,
get_project_repository,
)
from app.schemas.task_center import ListProjectTasksResponse, ProjectTaskResponse
from app.schemas.task_center import (
ListProjectTasksResponse,
ListTasksResponse,
ProjectTaskResponse,
UserTaskResponse,
)
from fastapi import APIRouter, Depends, HTTPException
from packages.application import (
@@ -34,28 +39,136 @@ def _humanize_task_error(error_message: str) -> str:
return f"任务失败:{raw}"
def _status_value(status) -> str:
"""安全获取状态值(兼容 StrEnum 和 plain string)。"""
return status.value if hasattr(status, "value") else str(status)
def _generation_step(task) -> str:
if task.status.value == "pending":
s = _status_value(task.status)
if s == "pending":
return "等待 Worker 执行"
if task.status.value == "running":
if s == "running":
return "正在生成成片"
if task.status.value == "completed":
if s == "completed":
return "生成完成"
if task.status.value == "failed":
if s == "failed":
return "生成失败"
return task.status.value
return s
def _ingest_step(job) -> str:
if job.status.value == "pending":
s = _status_value(job.status)
if s == "pending":
return "等待导入"
if job.status.value == "processing":
if s == "processing":
return "正在分析素材"
if job.status.value == "completed":
if s == "completed":
return "导入完成"
if job.status.value == "failed":
if s == "failed":
return "导入失败"
return job.status.value
return s
def _generation_task_to_project_response(task) -> ProjectTaskResponse:
return ProjectTaskResponse(
id=f"generation:{task.id}",
task_type="generation",
project_id=task.project_id,
status=_status_value(task.status),
progress=task.progress,
current_step=_generation_step(task),
error_message=task.error_message,
user_message=_humanize_task_error(task.error_message),
retryable=_status_value(task.status) == "failed",
source_id=task.id,
template_id=task.template_id,
created_at=task.created_at,
updated_at=task.completed_at or task.started_at or task.created_at,
)
# ── 用户级端点(放在项目级端点之前,避免路由冲突) ──
@router.get("/tasks", response_model=ListTasksResponse)
def list_user_tasks(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
generation_task_repository: Any = Depends(get_generation_task_repository),
) -> ListTasksResponse:
"""用户级任务列表(跨 project),合并 ingest + generation 任务。"""
user_id = authenticated_user.user.id
items: list[UserTaskResponse] = []
for task in generation_task_repository.list_by_user(user_id):
items.append(
UserTaskResponse(
id=f"generation:{task.id}",
task_type="generation",
project_id=task.project_id,
template_id=task.template_id,
status=_status_value(task.status),
progress=task.progress,
current_step=_generation_step(task),
error_message=task.error_message,
user_message=_humanize_task_error(task.error_message),
retryable=_status_value(task.status) == "failed",
source_id=task.id,
created_at=task.created_at,
updated_at=task.completed_at or task.started_at or task.created_at,
)
)
items.sort(key=lambda item: item.updated_at or item.created_at or "", reverse=True)
return ListTasksResponse(items=items)
@router.post("/tasks/{task_id}/retry", response_model=UserTaskResponse)
def retry_task_by_id(
task_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository),
) -> UserTaskResponse:
"""简化重试:通过 task_id 直接重试失败的生成任务。"""
task = generation_task_repository.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Generation task not found")
if task.created_by_user_id and task.created_by_user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="Access denied to this task")
if _status_value(task.status) != "failed":
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
use_case = CreateGenerationTaskUseCase(generation_task_repository)
retried = use_case.execute(
CreateGenerationTaskCommand(
project_id=task.project_id,
asset_library_id=task.asset_library_id,
strategy_id=task.strategy_id,
voice_library_id=task.voice_library_id,
template_id=task.template_id,
asset_ids=task.asset_ids,
title_ids=task.title_ids,
voice_ids=task.voice_ids,
created_by_user_id=authenticated_user.user.id,
)
)
celery_app.send_task("worker.generate_video", args=[retried.id])
return UserTaskResponse(
id=f"generation:{retried.id}",
task_type="generation",
project_id=retried.project_id,
template_id=retried.template_id,
status=_status_value(retried.status),
progress=retried.progress,
current_step=_generation_step(retried),
source_id=retried.id,
created_at=retried.created_at,
updated_at=retried.created_at,
)
# ── 项目级端点 ──
@router.get("/projects/{project_id}/tasks", response_model=ListProjectTasksResponse)
@@ -77,34 +190,19 @@ def list_project_tasks(
id=f"ingest:{job.id}",
task_type="ingest",
project_id=job.project_id,
status=job.status.value,
progress=100.0 if job.status.value == "completed" else 0.0,
status=_status_value(job.status),
progress=100.0 if _status_value(job.status) == "completed" else 0.0,
current_step=_ingest_step(job),
error_message=job.error_message,
user_message=_humanize_task_error(job.error_message),
retryable=job.status.value == "failed",
retryable=_status_value(job.status) == "failed",
source_id=job.id,
created_at=job.created_at,
updated_at=job.updated_at,
)
)
for task in generation_task_repository.list_by_project(project_id):
items.append(
ProjectTaskResponse(
id=f"generation:{task.id}",
task_type="generation",
project_id=task.project_id,
status=task.status.value,
progress=task.progress,
current_step=_generation_step(task),
error_message=task.error_message,
user_message=_humanize_task_error(task.error_message),
retryable=task.status.value == "failed",
source_id=task.id,
created_at=task.created_at,
updated_at=task.completed_at or task.started_at or task.created_at,
)
)
items.append(_generation_task_to_project_response(task))
items.sort(key=lambda item: item.updated_at or item.created_at or "", reverse=True)
return ListProjectTasksResponse(items=items)
@@ -121,7 +219,7 @@ def retry_project_task(
task = generation_task_repository.get(source_id)
if task is None:
raise HTTPException(status_code=404, detail="Generation task not found")
if task.status.value != "failed":
if _status_value(task.status) != "failed":
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
use_case = CreateGenerationTaskUseCase(generation_task_repository)
retried = use_case.execute(
@@ -130,27 +228,20 @@ def retry_project_task(
asset_library_id=task.asset_library_id,
strategy_id=task.strategy_id,
voice_library_id=task.voice_library_id,
edit_plan_id=task.edit_plan_id,
template_id=task.template_id,
asset_ids=task.asset_ids,
title_ids=task.title_ids,
voice_ids=task.voice_ids,
created_by_user_id=authenticated_user.user.id,
)
)
celery_app.send_task("worker.generate_video", args=[retried.id])
return ProjectTaskResponse(
id=f"generation:{retried.id}",
task_type="generation",
project_id=retried.project_id,
status=retried.status.value,
progress=retried.progress,
current_step=_generation_step(retried),
source_id=retried.id,
created_at=retried.created_at,
updated_at=retried.created_at,
)
return _generation_task_to_project_response(retried)
if task_type == "ingest":
job = ingest_job_repository.get(source_id)
if job is None:
raise HTTPException(status_code=404, detail="Ingest job not found")
if job.status.value != "failed":
if _status_value(job.status) != "failed":
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
use_case = SubmitIngestJobUseCase(ingest_job_repository)
retried = use_case.execute(
@@ -165,7 +256,7 @@ def retry_project_task(
id=f"ingest:{retried.id}",
task_type="ingest",
project_id=retried.project_id,
status=retried.status.value,
status=_status_value(retried.status),
progress=0,
current_step=_ingest_step(retried),
source_id=retried.id,
+30
View File
@@ -0,0 +1,30 @@
from datetime import datetime
from pydantic import BaseModel, Field
class RecentTaskItem(BaseModel):
id: str
task_type: str = "generation"
status: str
current_step: str = ""
error_message: str = ""
updated_at: datetime | None = None
class SubscriptionInfo(BaseModel):
"""用户订阅信息。"""
plan: str = "free"
is_active: bool = False
class DashboardOverviewResponse(BaseModel):
"""Dashboard 概览数据。"""
total_assets: int = 0
used_storage_bytes: int = 0
total_titles: int = 0
total_voices: int = 0
total_tasks: int = 0
total_products: int = 0
subscription: SubscriptionInfo = Field(default_factory=SubscriptionInfo)
recent_tasks: list[RecentTaskItem] = Field(default_factory=list)
+35 -3
View File
@@ -1,12 +1,35 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
class CreateGenerationTaskRequest(BaseModel):
project_id: str = Field(..., min_length=1)
asset_library_id: str = Field(..., min_length=1)
"""创建生成任务请求。
支持两种模式(至少提供一种):
- 项目模式:project_id + asset_library_id(向后兼容)
- 模板模式:template_id + asset_ids / title_ids / voice_ids
"""
project_id: str = ""
asset_library_id: str = ""
strategy_id: str = ""
voice_library_id: str = ""
created_by_user_id: str = ""
# ── 模板模式新增字段 ──
template_id: str = ""
asset_ids: list[str] = Field(default_factory=list)
title_ids: list[str] = Field(default_factory=list)
voice_ids: list[str] = Field(default_factory=list)
@model_validator(mode="after")
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
has_project = bool(self.project_id.strip())
has_template = bool(self.template_id.strip())
if not has_project and not has_template:
raise ValueError("project_id 或 template_id 至少需要提供一个")
has_library = bool(self.asset_library_id.strip())
has_assets = bool(self.asset_ids or self.title_ids or self.voice_ids)
if not has_library and not has_assets:
raise ValueError("asset_library_id 或 asset_ids/title_ids/voice_ids 至少需要提供一个")
return self
class GenerationTaskResponse(BaseModel):
@@ -15,7 +38,16 @@ class GenerationTaskResponse(BaseModel):
asset_library_id: str
strategy_id: str
voice_library_id: str
template_id: str = ""
asset_ids: list[str] = Field(default_factory=list)
title_ids: list[str] = Field(default_factory=list)
voice_ids: list[str] = Field(default_factory=list)
status: str
progress: float
result_count: int
error_message: str
class ListGenerationTasksResponse(BaseModel):
"""用户级生成任务列表响应(跨 project)。"""
items: list[GenerationTaskResponse]
+23
View File
@@ -14,9 +14,32 @@ class ProjectTaskResponse(BaseModel):
user_message: str = ""
retryable: bool = False
source_id: str = ""
template_id: str = ""
created_at: datetime | None = None
updated_at: datetime | None = None
class ListProjectTasksResponse(BaseModel):
items: list[ProjectTaskResponse] = Field(default_factory=list)
class UserTaskResponse(BaseModel):
"""用户级任务响应(跨 project,用于模板模式)。"""
id: str
task_type: str
project_id: str = ""
template_id: str = ""
status: str
progress: float
current_step: str
error_message: str = ""
user_message: str = ""
retryable: bool = False
source_id: str = ""
created_at: datetime | None = None
updated_at: datetime | None = None
class ListTasksResponse(BaseModel):
"""用户级任务列表响应(GET /api/v1/tasks)。"""
items: list[UserTaskResponse] = Field(default_factory=list)
-98
View File
@@ -1,98 +0,0 @@
/**
* 编辑计划 API
* Phase 1 重构:去掉 projectId,编辑计划直接归属用户
*/
import apiClient from './client';
/** 编辑模式 */
export type EditingMode = 'one-take' | 'pip' | 'voiceover' | 'voice_pip';
/** 编辑模板 */
export interface EditTemplateItem {
id: string;
name: string;
description: string;
target_duration: number;
clip_count: number;
is_active: boolean;
category?: string;
thumbnail_url?: string;
}
/** 编辑计划片段 */
export interface EditPlanClipItem {
id: string;
asset_id: string;
asset_name: string;
sequence: number;
start_time: number;
duration: number;
reason: string;
layer?: 'main' | 'pip' | 'broll';
thumbnail_url?: string;
}
/** 编辑计划 */
export interface EditPlanItem {
id: string;
template_id: string;
asset_library_id: string;
title_id: string;
status: string;
editing_mode?: EditingMode;
summary: string;
clips: EditPlanClipItem[];
created_at?: string;
updated_at?: string;
}
// ─── 编辑计划 ──────────────────────────────────────────────
/** 获取当前用户的编辑计划列表 */
export const getEditPlans = async (): Promise<EditPlanItem[]> => {
const response = await apiClient.get('/edit-plans');
return response.data.items || [];
};
/** 获取单个编辑计划 */
export const getEditPlan = async (planId: string): Promise<EditPlanItem> => {
const response = await apiClient.get(`/edit-plans/${planId}`);
return response.data;
};
/** 创建编辑计划 */
export const createEditPlan = async (data: {
asset_library_id: string;
template_id?: string;
title_id?: string;
}): Promise<EditPlanItem> => {
const response = await apiClient.post('/edit-plans', data);
return response.data;
};
/** 智能编排 - 自动生成编辑计划 */
export const autoGenerateEditPlan = async (params: {
template_id: string;
asset_ids?: string[];
title_ids?: string[];
voice_ids?: string[];
editing_mode?: EditingMode;
target_duration?: number;
}): Promise<EditPlanItem> => {
const response = await apiClient.post('/edit-plans/auto-generate', params);
return response.data;
};
/** 更新编辑计划 */
export const updateEditPlan = async (
planId: string,
data: Partial<EditPlanItem>
): Promise<EditPlanItem> => {
const response = await apiClient.patch(`/edit-plans/${planId}`, data);
return response.data;
};
/** 删除编辑计划 */
export const deleteEditPlan = async (planId: string): Promise<void> => {
await apiClient.delete(`/edit-plans/${planId}`);
};
+52 -172
View File
@@ -1,8 +1,8 @@
/**
* 剪辑计划编辑器 API
* 当前使用 mock 数据,后端 API 就绪后替换
* 对接后端 /api/v1/templates 路由
*/
// import apiClient from './client'; // TODO: 后端 API 就绪后启用
import apiClient from './client';
/* ──────────── 类型定义 ──────────── */
@@ -53,11 +53,11 @@ export interface BgmConfig {
/** 模板片段 */
export interface TemplateSegment {
id: string;
id?: string;
segment_order: number;
duration_min: number;
duration_max: number;
material_type: string | null; // 仅 口播+混剪 模式:人物/场景
material_type: string | null;
}
/** 剪辑模板 */
@@ -72,6 +72,7 @@ export interface EditingTemplate {
bgm_config: BgmConfig;
estimated_duration: number;
segments: TemplateSegment[];
is_active?: boolean;
created_at: string;
updated_at: string;
}
@@ -80,6 +81,7 @@ export interface EditingTemplate {
export interface TemplateCategory {
id: string;
name: string;
created_at?: string;
}
/** 创建/更新模板请求体 */
@@ -100,124 +102,46 @@ export interface GenerateFromTemplatePayload {
voiceover_duration: number;
}
/** 使用模板生成响应 */
export interface GenerateFromTemplateResponse {
task_id: string;
warning?: string;
/** 验证/生成响应 */
export interface ValidateWarning {
code: string;
message: string;
details?: Record<string, unknown>;
}
/* ──────────── Mock 数据 ──────────── */
/** 使用模板生成响应 */
export interface GenerateFromTemplateResponse {
template: EditingTemplate;
warnings: ValidateWarning[];
}
let _nextId = 100;
const nextId = () => String(++_nextId);
/** 列表响应(带分页) */
export interface ListTemplatesResponse {
items: EditingTemplate[];
total: number;
}
const MOCK_CATEGORIES: TemplateCategory[] = [
{ id: 'cat-1', name: '生活' },
{ id: 'cat-2', name: '美食' },
{ id: 'cat-3', name: '旅行' },
{ id: 'cat-4', name: '知识' },
];
/** 分类列表响应 */
export interface ListCategoriesResponse {
items: TemplateCategory[];
}
const MOCK_TEMPLATES: EditingTemplate[] = [
{
id: 'tpl-1',
name: '生活 Vlog 模板',
mode: 'pip',
category: '生活',
tags: ['vlog', '日常'],
title_config: {
ai_auto_select: true,
content: '',
font_preset: '思源黑体',
font_color: '#ffffff',
font_size: 32,
position: 'top',
},
subtitle_config: {
enabled: true,
position: 'bottom',
font: '思源黑体',
color: '#ffffff',
size: 24,
animation: 'fade',
},
bgm_config: { enabled: true, music_id: 'bgm-1' },
estimated_duration: 30,
segments: [
{ id: 'seg-1', segment_order: 1, duration_min: 5, duration_max: 15, material_type: null },
{ id: 'seg-2', segment_order: 2, duration_min: 10, duration_max: 20, material_type: null },
],
created_at: '2026-06-20T10:00:00Z',
updated_at: '2026-06-20T10:00:00Z',
},
{
id: 'tpl-2',
name: '知识分享口播',
mode: 'voice_over',
category: '知识',
tags: ['口播', '分享'],
title_config: {
ai_auto_select: false,
content: '每日知识分享',
font_preset: '站酷快乐体',
font_color: '#ffdd00',
font_size: 36,
position: 'top',
},
subtitle_config: {
enabled: true,
position: 'bottom',
font: '思源黑体',
color: '#ffffff',
size: 28,
animation: 'typewriter',
},
bgm_config: { enabled: false, music_id: '' },
estimated_duration: 60,
segments: [
{ id: 'seg-3', segment_order: 1, duration_min: 10, duration_max: 30, material_type: null },
{ id: 'seg-4', segment_order: 2, duration_min: 20, duration_max: 40, material_type: null },
{ id: 'seg-5', segment_order: 3, duration_min: 10, duration_max: 20, material_type: null },
],
created_at: '2026-06-21T10:00:00Z',
updated_at: '2026-06-21T10:00:00Z',
},
{
id: 'tpl-3',
name: '一镜到底展示',
mode: 'one_take',
category: '生活',
tags: ['一镜到底'],
title_config: {
ai_auto_select: true,
content: '',
font_preset: '思源黑体',
font_color: '#ffffff',
font_size: 32,
position: 'center',
},
subtitle_config: { enabled: false, position: 'bottom', font: '思源黑体', color: '#ffffff', size: 24, animation: 'fade' },
bgm_config: { enabled: true, music_id: 'bgm-2' },
estimated_duration: 15,
segments: [
{ id: 'seg-6', segment_order: 1, duration_min: 10, duration_max: 20, material_type: null },
],
created_at: '2026-06-22T10:00:00Z',
updated_at: '2026-06-22T10:00:00Z',
},
];
/* ──────────── Mock API 函数 ──────────── */
const delay = (ms = 200) => new Promise((r) => setTimeout(r, ms));
// ============ API 函数 ============
/** 获取模板列表 */
export const getEditingTemplates = async (params?: {
category?: string;
tag?: string;
skip?: number;
limit?: number;
}): Promise<EditingTemplate[]> => {
await delay();
let list = [...MOCK_TEMPLATES];
const response = await apiClient.get<ListTemplatesResponse>('/templates', {
params: {
skip: params?.skip ?? 0,
limit: params?.limit ?? 50,
},
});
let list = response.data.items;
if (params?.category) list = list.filter((t) => t.category === params.category);
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!));
return list;
@@ -225,38 +149,16 @@ export const getEditingTemplates = async (params?: {
/** 获取模板详情 */
export const getEditingTemplate = async (id: string): Promise<EditingTemplate> => {
await delay();
const tpl = MOCK_TEMPLATES.find((t) => t.id === id);
if (!tpl) throw new Error('模板不存在');
return { ...tpl };
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`);
return response.data;
};
/** 创建模板 */
export const createEditingTemplate = async (
data: SaveTemplatePayload,
): Promise<EditingTemplate> => {
await delay(300);
const now = new Date().toISOString();
const tpl: EditingTemplate = {
id: nextId(),
name: data.name,
mode: data.mode,
category: data.category,
tags: data.tags,
title_config: data.title_config,
subtitle_config: data.subtitle_config,
bgm_config: data.bgm_config,
estimated_duration: data.estimated_duration,
segments: data.segments.map((s, i) => ({
...s,
id: nextId(),
segment_order: i + 1,
})),
created_at: now,
updated_at: now,
};
MOCK_TEMPLATES.push(tpl);
return tpl;
const response = await apiClient.post<EditingTemplate>('/templates', data);
return response.data;
};
/** 更新模板 */
@@ -264,51 +166,29 @@ export const updateEditingTemplate = async (
id: string,
data: SaveTemplatePayload,
): Promise<EditingTemplate> => {
await delay(300);
const idx = MOCK_TEMPLATES.findIndex((t) => t.id === id);
if (idx === -1) throw new Error('模板不存在');
const updated: EditingTemplate = {
...MOCK_TEMPLATES[idx],
name: data.name,
mode: data.mode,
category: data.category,
tags: data.tags,
title_config: data.title_config,
subtitle_config: data.subtitle_config,
bgm_config: data.bgm_config,
estimated_duration: data.estimated_duration,
segments: data.segments.map((s, i) => ({
...s,
id: nextId(),
segment_order: i + 1,
})),
updated_at: new Date().toISOString(),
};
MOCK_TEMPLATES[idx] = updated;
return updated;
const response = await apiClient.patch<EditingTemplate>(`/templates/${id}`, data);
return response.data;
};
/** 删除模板 */
export const deleteEditingTemplate = async (id: string): Promise<void> => {
await delay();
const idx = MOCK_TEMPLATES.findIndex((t) => t.id === id);
if (idx !== -1) MOCK_TEMPLATES.splice(idx, 1);
await apiClient.delete(`/templates/${id}`);
};
/** 获取模板分类列表 */
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
await delay();
return [...MOCK_CATEGORIES];
const response = await apiClient.get<ListCategoriesResponse>('/templates/categories/list');
return response.data.items;
};
/** 使用模板生成视频 */
/** 使用模板生成视频(调用 validate 端点) */
export const generateFromTemplate = async (
_templateId: string,
_data: GenerateFromTemplatePayload,
templateId: string,
data: GenerateFromTemplatePayload,
): Promise<GenerateFromTemplateResponse> => {
await delay(500);
return {
task_id: nextId(),
warning: undefined,
};
const response = await apiClient.post<GenerateFromTemplateResponse>(
`/templates/${templateId}/validate`,
data,
);
return response.data;
};
+37 -29
View File
@@ -1,13 +1,20 @@
/**
* 任务相关 API
* Phase 1 重构:去掉 projectId,任务直接归属用户
* 对接后端方案 A 扩展后的端点(PR #109)
* - POST /api/v1/generation/tasks — 创建生成任务(template_id + asset_ids 细粒度模式)
* - GET /api/v1/tasks — 用户级任务列表(跨 project)
* - POST /api/v1/tasks/{task_id}/retry — 简化重试
*/
import apiClient from './client';
/** 任务条目 */
/* ──────────── 类型定义 ──────────── */
/** 任务条目(对应用户级 UserTaskResponse */
export interface TaskItem {
id: string;
task_type: 'ingest' | 'generation' | string;
project_id: string;
template_id: string;
status: string;
progress: number;
current_step: string;
@@ -19,18 +26,6 @@ export interface TaskItem {
updated_at?: string | null;
}
/** 获取当前用户的所有任务(生成记录) */
export const getUserTasks = async (): Promise<TaskItem[]> => {
const response = await apiClient.get('/tasks');
return response.data.items || [];
};
/** 重试失败的任务 */
export const retryTask = async (taskId: string): Promise<TaskItem> => {
const response = await apiClient.post(`/tasks/${taskId}/retry`);
return response.data;
};
/** 创建生成任务请求参数 */
export interface CreateGenerationTaskRequest {
template_id: string;
@@ -39,31 +34,44 @@ export interface CreateGenerationTaskRequest {
voice_ids: string[];
}
/** 创建生成任务响应 */
/** 创建生成任务响应(对齐后端 GenerationTaskResponse */
export interface CreateGenerationTaskResponse {
task_id: string;
id: string;
project_id: string;
asset_library_id: string;
strategy_id: string;
voice_library_id: string;
template_id: string;
asset_ids: string[];
title_ids: string[];
voice_ids: string[];
status: string;
message: string;
progress: number;
result_count: number;
error_message: string;
}
// TODO: 后端生成接口适配扁平化架构后切换为 false
const USE_MOCK = true;
/* ──────────── API 函数 ──────────── */
/** 创建生成任务(一键生成) */
export const createGenerationTask = async (
params: CreateGenerationTaskRequest,
): Promise<CreateGenerationTaskResponse> => {
if (USE_MOCK) {
await new Promise((r) => setTimeout(r, 800));
return {
task_id: `task_${Date.now()}`,
status: 'pending',
message: '生成任务已创建',
};
}
const response = await apiClient.post<CreateGenerationTaskResponse>(
const { data } = await apiClient.post<CreateGenerationTaskResponse>(
'/generation/tasks',
params,
);
return response.data;
return data;
};
/** 获取当前用户的所有任务(跨 project) */
export const getUserTasks = async (): Promise<TaskItem[]> => {
const { data } = await apiClient.get('/tasks');
return data.items || [];
};
/** 重试失败的任务 */
export const retryTask = async (taskId: string): Promise<TaskItem> => {
const { data } = await apiClient.post(`/tasks/${taskId}/retry`);
return data;
};
@@ -168,7 +168,7 @@ const EditingPlanner: React.FC = () => {
mutationFn: ({ templateId, duration }: { templateId: string; duration: number }) =>
generateFromTemplate(templateId, { voiceover_duration: duration }),
onSuccess: (data) => {
const msg = data.warning ? `生成任务已提交(${data.warning}` : '生成任务已提交';
const msg = data.warnings && data.warnings.length > 0 ? `生成任务已提交(${data.warnings.map(w => w.message).join('; ')}` : '生成任务已提交';
message.success(msg);
setGenerateModalOpen(false);
},
@@ -136,7 +136,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => onRemoveSegment(seg.id)}
onClick={() => onRemoveSegment(seg.id!)}
disabled={isOneShot}
style={{ marginLeft: 'auto' }}
/>
@@ -154,7 +154,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
min={1}
max={seg.duration_max}
value={seg.duration_min}
onChange={(v) => onUpdateSegment(seg.id, { duration_min: v })}
onChange={(v) => onUpdateSegment(seg.id!, { duration_min: v })}
/>
</div>
<div>
@@ -163,7 +163,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
min={seg.duration_min}
max={60}
value={seg.duration_max}
onChange={(v) => onUpdateSegment(seg.id, { duration_max: v })}
onChange={(v) => onUpdateSegment(seg.id!, { duration_max: v })}
/>
</div>
</>
@@ -175,7 +175,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
<Select
size="small"
value={seg.material_type || '人物'}
onChange={(v) => onUpdateSegment(seg.id, { material_type: v })}
onChange={(v) => onUpdateSegment(seg.id!, { material_type: v })}
style={{ width: '100%', marginTop: 4 }}
options={[
{ value: '人物', label: '人物' },
@@ -11,7 +11,7 @@ class SQLAlchemyAssetRepository:
def __init__(self, session: Session):
self.session = session
async def find_by_library(
def find_by_library(
self,
library_id: str,
skip: int = 0,
@@ -22,7 +22,7 @@ class SQLAlchemyAssetRepository:
).offset(skip).limit(limit).all()
return [self._to_domain(model) for model in models]
async def find_by_project(
def find_by_project(
self,
project_id: str,
skip: int = 0,
@@ -33,7 +33,7 @@ class SQLAlchemyAssetRepository:
).offset(skip).limit(limit).all()
return [self._to_domain(model) for model in models]
async def find_by_id(self, asset_id: str) -> Asset | None:
def find_by_id(self, asset_id: str) -> Asset | None:
model = self.session.query(AssetModel).filter(AssetModel.id == asset_id).first()
if model is None:
return None
@@ -42,7 +42,7 @@ class SQLAlchemyAssetRepository:
def get(self, asset_id: str) -> Asset | None:
return self.find_by_id(asset_id)
async def create(self, asset: Asset) -> Asset:
def create(self, asset: Asset) -> Asset:
now = datetime.now(timezone.utc)
model = AssetModel(
id=asset.id,
@@ -70,7 +70,7 @@ class SQLAlchemyAssetRepository:
self.session.commit()
return asset
async def update(self, asset: Asset) -> Asset:
def update(self, asset: Asset) -> Asset:
model = self.session.query(AssetModel).filter(AssetModel.id == asset.id).first()
if model is None:
raise ValueError(f"Asset {asset.id} not found")
@@ -92,7 +92,7 @@ class SQLAlchemyAssetRepository:
self.session.commit()
return asset
async def delete(self, asset_id: str) -> bool:
def delete(self, asset_id: str) -> bool:
model = self.session.query(AssetModel).filter(AssetModel.id == asset_id).first()
if model:
self.session.delete(model)
@@ -100,11 +100,27 @@ class SQLAlchemyAssetRepository:
return True
return False
async def count_by_project(self, project_id: str) -> int:
def count_by_project(self, project_id: str) -> int:
return self.session.query(AssetModel).filter(
AssetModel.project_id == project_id
).count()
def count_by_project_ids(self, project_ids: list[str]) -> int:
if not project_ids:
return 0
return self.session.query(AssetModel).filter(
AssetModel.project_id.in_(project_ids)
).count()
def sum_storage_by_project_ids(self, project_ids: list[str]) -> int:
if not project_ids:
return 0
from sqlalchemy import func
result = self.session.query(func.coalesce(func.sum(AssetModel.file_size), 0)).filter(
AssetModel.project_id.in_(project_ids)
).scalar()
return int(result or 0)
def _to_domain(self, model: AssetModel) -> Asset:
metadata = {}
if model.classification_result:
@@ -4,6 +4,29 @@ from packages.adapters.sqlalchemy_impl.models import GenerationTaskModel
from packages.domain import GenerationTask
def _to_domain(model: GenerationTaskModel) -> GenerationTask:
"""Convert ORM model to domain entity."""
return GenerationTask(
id=model.id,
project_id=model.project_id,
strategy_id=model.strategy_id,
asset_library_id=model.asset_library_id,
voice_library_id=model.voice_library_id,
template_id=model.template_id,
asset_ids=list(model.asset_ids or []),
title_ids=list(model.title_ids or []),
voice_ids=list(model.voice_ids or []),
status=model.status,
progress=model.progress,
result_count=int(model.result_count or 0),
error_message=model.error_message,
started_at=model.started_at,
completed_at=model.completed_at,
created_by_user_id=model.created_by_user_id,
created_at=model.created_at,
)
class SQLAlchemyGenerationTaskRepository:
def __init__(self, session: Session):
self.session = session
@@ -15,6 +38,10 @@ class SQLAlchemyGenerationTaskRepository:
strategy_id=task.strategy_id,
asset_library_id=task.asset_library_id,
voice_library_id=task.voice_library_id,
template_id=task.template_id,
asset_ids=task.asset_ids,
title_ids=task.title_ids,
voice_ids=task.voice_ids,
status=task.status,
progress=task.progress,
result_count=task.result_count,
@@ -32,31 +59,55 @@ class SQLAlchemyGenerationTaskRepository:
model = self.session.query(GenerationTaskModel).filter(GenerationTaskModel.id == task_id).first()
if model is None:
return None
return GenerationTask(
id=model.id,
project_id=model.project_id,
strategy_id=model.strategy_id,
asset_library_id=model.asset_library_id,
voice_library_id=model.voice_library_id,
status=model.status,
progress=model.progress,
result_count=int(model.result_count or 0),
error_message=model.error_message,
started_at=model.started_at,
completed_at=model.completed_at,
created_by_user_id=model.created_by_user_id,
created_at=model.created_at,
)
return _to_domain(model)
def list_by_project(self, project_id: str) -> list[GenerationTask]:
models = self.session.query(GenerationTaskModel).filter(GenerationTaskModel.project_id == project_id).all()
return [self.get(model.id) for model in models if self.get(model.id) is not None]
models = (
self.session.query(GenerationTaskModel)
.filter(GenerationTaskModel.project_id == project_id)
.order_by(GenerationTaskModel.created_at.desc())
.all()
)
return [_to_domain(m) for m in models]
def list_by_user(self, user_id: str) -> list[GenerationTask]:
models = (
self.session.query(GenerationTaskModel)
.filter(GenerationTaskModel.created_by_user_id == user_id)
.order_by(GenerationTaskModel.created_at.desc())
.all()
)
return [_to_domain(m) for m in models]
def count_by_user(self, user_id: str) -> int:
return (
self.session.query(GenerationTaskModel)
.filter(GenerationTaskModel.created_by_user_id == user_id)
.count()
)
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]:
models = (
self.session.query(GenerationTaskModel)
.filter(GenerationTaskModel.created_by_user_id == user_id)
.order_by(GenerationTaskModel.created_at.desc())
.limit(limit)
.all()
)
return [_to_domain(m) for m in models]
def update(self, task: GenerationTask) -> GenerationTask:
model = self.session.query(GenerationTaskModel).filter(GenerationTaskModel.id == task.id).first()
if model is None:
raise ValueError(f"GenerationTask {task.id} not found")
model.project_id = task.project_id
model.asset_library_id = task.asset_library_id
model.strategy_id = task.strategy_id
model.voice_library_id = task.voice_library_id
model.template_id = task.template_id
model.asset_ids = task.asset_ids
model.title_ids = task.title_ids
model.voice_ids = task.voice_ids
model.status = task.status
model.progress = task.progress
model.result_count = task.result_count
+7 -5
View File
@@ -65,8 +65,6 @@ class AssetModel(Base):
asset_library_id = Column(String(36), nullable=False, index=True)
name = Column(String(500), nullable=False)
file_type = Column(String(20), nullable=False, index=True)
# storage_key: OSS 对象键(相对路径),用于内部存储和操作
storage_key = Column(String(255), nullable=False)
# file_size: 文件大小(字节),使用 Integer 类型以确保精确性
file_size = Column(Integer, nullable=False)
# file_url: 完整可访问的 URL,用于客户端直接访问文件
@@ -139,10 +137,14 @@ class GenerationTaskModel(Base):
__tablename__ = "generation_tasks"
id = Column(String(32), primary_key=True)
project_id = Column(String(32), nullable=False, index=True)
project_id = Column(String(32), nullable=False, default="", index=True)
strategy_id = Column(String(32), nullable=False, default="")
asset_library_id = Column(String(32), nullable=False, index=True)
asset_library_id = Column(String(32), nullable=False, default="", index=True)
voice_library_id = Column(String(32), nullable=False, default="")
template_id = Column(String(36), nullable=False, default="", index=True)
asset_ids = Column(JSON, nullable=False, default=list)
title_ids = Column(JSON, nullable=False, default=list)
voice_ids = Column(JSON, nullable=False, default=list)
editing_mode = Column(String(20), nullable=False, default="one_take", index=True) # 剪辑模式: one_take, pip, voice_over, voice_pip
status = Column(String(20), nullable=False, default="pending", index=True)
progress = Column(Float, nullable=False, default=0.0)
@@ -150,7 +152,7 @@ class GenerationTaskModel(Base):
error_message = Column(Text, nullable=False, default="")
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
created_by_user_id = Column(String(32), nullable=False, default="")
created_by_user_id = Column(String(32), nullable=False, default="", index=True)
extra_meta = Column('metadata', JSON, nullable=False, default=dict)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
+11 -5
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from uuid import uuid4
from packages.domain import GenerationTask
@@ -9,11 +9,14 @@ from packages.ports.generation_task_repository import GenerationTaskRepository
@dataclass(slots=True)
class CreateGenerationTaskCommand:
project_id: str
asset_library_id: str
project_id: str = ""
asset_library_id: str = ""
strategy_id: str = ""
voice_library_id: str = ""
edit_plan_id: str = ""
template_id: str = ""
asset_ids: list[str] = field(default_factory=list)
title_ids: list[str] = field(default_factory=list)
voice_ids: list[str] = field(default_factory=list)
created_by_user_id: str = ""
@@ -28,7 +31,10 @@ class CreateGenerationTaskUseCase:
asset_library_id=command.asset_library_id,
strategy_id=command.strategy_id,
voice_library_id=command.voice_library_id,
edit_plan_id=command.edit_plan_id,
template_id=command.template_id,
asset_ids=command.asset_ids,
title_ids=command.title_ids,
voice_ids=command.voice_ids,
status="pending",
progress=0.0,
result_count=0,
-65
View File
@@ -250,71 +250,6 @@ class IngestJob:
)
# 继续读取其他实体定义 - 生成任务、生成视频等
@dataclass(slots=True)
class ClassificationJob:
id: str
project_id: str
asset_id: str
status: str = "pending"
classification: str = ""
confidence: float = 0.0
error_message: str = ""
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@dataclass(slots=True)
class GenerationTask:
id: str
project_id: str
asset_library_id: str
strategy_id: str = ""
voice_library_id: str = ""
edit_plan_id: str = ""
status: str = "pending"
progress: float = 0.0
result_count: int = 0
error_message: str = ""
started_at: datetime | None = None
completed_at: datetime | None = None
created_by_user_id: str = ""
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@dataclass(slots=True)
class GeneratedVideo:
id: str
project_id: str
generation_task_id: str
name: str
file_url: str
file_size: float = 0.0
duration: float = 0.0
thumbnail_url: str | None = None
width: int = 0
height: int = 0
fps: float = 0.0
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))
updated_at: datetime | None = None
@dataclass(slots=True)
class EditTemplate:
id: str
project_id: str
name: str
description: str = ""
target_duration: float = 30.0
clip_count: int = 3
is_active: bool = True
created_by_user_id: str = ""
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+16 -4
View File
@@ -21,6 +21,10 @@ class GenerationTask:
asset_library_id: str
strategy_id: str = ""
voice_library_id: str = ""
template_id: str = ""
asset_ids: list[str] = field(default_factory=list)
title_ids: list[str] = field(default_factory=list)
voice_ids: list[str] = field(default_factory=list)
status: GenerationTaskStatus = GenerationTaskStatus.PENDING
progress: float = 0.0
result_count: int = 0
@@ -38,17 +42,25 @@ class GenerationTask:
*,
strategy_id: str = "",
voice_library_id: str = "",
template_id: str = "",
asset_ids: list[str] | None = None,
title_ids: list[str] | None = None,
voice_ids: list[str] | None = None,
created_by_user_id: str = "",
) -> "GenerationTask":
if not project_id.strip():
raise ValueError("project_id 不能为空")
if not asset_library_id.strip():
raise ValueError("asset_library_id 不能为空")
if not project_id.strip() and not template_id.strip():
raise ValueError("project_id 或 template_id 至少需要提供一个")
if not asset_library_id.strip() and not (asset_ids or title_ids or voice_ids):
raise ValueError("asset_library_id 或 asset_ids/title_ids/voice_ids 至少需要提供一个")
return cls(
id=uuid4().hex,
project_id=project_id.strip(),
asset_library_id=asset_library_id.strip(),
strategy_id=strategy_id.strip(),
voice_library_id=voice_library_id.strip(),
template_id=template_id.strip(),
asset_ids=list(asset_ids) if asset_ids else [],
title_ids=list(title_ids) if title_ids else [],
voice_ids=list(voice_ids) if voice_ids else [],
created_by_user_id=created_by_user_id.strip(),
)
+16 -8
View File
@@ -1,4 +1,4 @@
"""兼容层:旧素材仓储接口定义,保留给遗留异步适配器使用"""
"""素材仓储接口定义。"""
from abc import ABC, abstractmethod
@@ -7,15 +7,15 @@ from packages.domain import Asset
class AssetRepository(ABC):
@abstractmethod
async def create(self, asset: Asset) -> Asset:
def create(self, asset: Asset) -> Asset:
pass
@abstractmethod
async def find_by_id(self, asset_id: str) -> Asset | None:
def find_by_id(self, asset_id: str) -> Asset | None:
pass
@abstractmethod
async def find_by_project(
def find_by_project(
self,
project_id: str,
skip: int = 0,
@@ -24,7 +24,7 @@ class AssetRepository(ABC):
pass
@abstractmethod
async def find_by_library(
def find_by_library(
self,
library_id: str,
skip: int = 0,
@@ -33,13 +33,21 @@ class AssetRepository(ABC):
pass
@abstractmethod
async def update(self, asset: Asset) -> Asset:
def update(self, asset: Asset) -> Asset:
pass
@abstractmethod
async def delete(self, asset_id: str) -> bool:
def delete(self, asset_id: str) -> bool:
pass
@abstractmethod
async def count_by_project(self, project_id: str) -> int:
def count_by_project(self, project_id: str) -> int:
pass
@abstractmethod
def count_by_project_ids(self, project_ids: list[str]) -> int:
pass
@abstractmethod
def sum_storage_by_project_ids(self, project_ids: list[str]) -> int:
pass
@@ -12,4 +12,10 @@ class GenerationTaskRepository(Protocol):
def list_by_project(self, project_id: str) -> list[GenerationTask]: ...
def list_by_user(self, user_id: str) -> list[GenerationTask]: ...
def count_by_user(self, user_id: str) -> int: ...
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]: ...
def update(self, task: GenerationTask) -> GenerationTask: ...