feat(phase7): align asset flow and scaffold generation pipeline
This commit is contained in:
@@ -3,6 +3,8 @@ from fastapi import APIRouter
|
||||
from app.api.routes.asset_libraries import router as asset_libraries_router
|
||||
from app.api.routes.assets import router as assets_router
|
||||
from app.api.routes.classification_jobs import router as classification_jobs_router
|
||||
from app.api.routes.generated_videos import router as generated_videos_router
|
||||
from app.api.routes.generation_tasks import router as generation_tasks_router
|
||||
from app.api.routes.health import router as health_check_router
|
||||
from app.api.routes.ingest_jobs import router as ingest_jobs_router
|
||||
from app.api.routes.project_management import router as project_management_router
|
||||
@@ -43,6 +45,16 @@ api_router.include_router(
|
||||
prefix="/upload",
|
||||
tags=["文件上传"],
|
||||
)
|
||||
api_router.include_router(
|
||||
generation_tasks_router,
|
||||
prefix="/generation",
|
||||
tags=["生成任务"],
|
||||
)
|
||||
api_router.include_router(
|
||||
generated_videos_router,
|
||||
prefix="/generated-videos",
|
||||
tags=["成片管理"],
|
||||
)
|
||||
api_router.include_router(
|
||||
project_management_router,
|
||||
prefix="/project-management",
|
||||
|
||||
@@ -9,6 +9,18 @@ from packages.domain import AssetLibraryKind
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _to_asset_library_response(item) -> AssetLibraryResponse:
|
||||
return AssetLibraryResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
name=item.name,
|
||||
kind=item.kind.value,
|
||||
asset_count=item.asset_count,
|
||||
total_size=item.total_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ListAssetLibrariesResponse)
|
||||
def list_asset_libraries(
|
||||
project_id: str,
|
||||
@@ -18,18 +30,7 @@ def list_asset_libraries(
|
||||
use_case = ListAssetLibrariesUseCase(asset_library_repository)
|
||||
parsed_kind = AssetLibraryKind(kind) if kind else None
|
||||
items = use_case.execute(project_id, kind=parsed_kind)
|
||||
return ListAssetLibrariesResponse(
|
||||
items=[
|
||||
AssetLibraryResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
name=item.name,
|
||||
kind=item.kind.value,
|
||||
)
|
||||
for item in items
|
||||
]
|
||||
)
|
||||
return ListAssetLibrariesResponse(items=[_to_asset_library_response(item) for item in items])
|
||||
|
||||
|
||||
@router.post("", response_model=AssetLibraryResponse)
|
||||
@@ -46,10 +47,4 @@ def create_asset_library(
|
||||
kind=AssetLibraryKind(request.kind),
|
||||
)
|
||||
)
|
||||
return AssetLibraryResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
name=item.name,
|
||||
kind=item.kind.value,
|
||||
)
|
||||
return _to_asset_library_response(item)
|
||||
|
||||
@@ -4,10 +4,35 @@ from app.dependencies import get_asset_repository
|
||||
from app.schemas.asset import AssetResponse, CreateAssetRequest, ListAssetsResponse
|
||||
from packages.adapters.sqlalchemy_impl import SQLAlchemyAssetRepository
|
||||
from packages.application import CreateAssetCommand, CreateAssetUseCase, ListAssetsUseCase
|
||||
from packages.domain import AssetStatus, ClassificationStatus
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _to_asset_response(item) -> AssetResponse:
|
||||
return AssetResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
library_id=item.library_id,
|
||||
name=item.name,
|
||||
storage_key=item.storage_key,
|
||||
mime_type=item.mime_type,
|
||||
metadata=item.metadata,
|
||||
file_size=item.file_size,
|
||||
thumbnail_url=item.thumbnail_url,
|
||||
duration=item.duration,
|
||||
width=item.width,
|
||||
height=item.height,
|
||||
fps=item.fps,
|
||||
codec=item.codec,
|
||||
status=item.status.value,
|
||||
classification_status=item.classification_status.value,
|
||||
quality_score=item.quality_score,
|
||||
uploaded_by_user_id=item.uploaded_by_user_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ListAssetsResponse)
|
||||
def list_assets(
|
||||
library_id: str,
|
||||
@@ -15,21 +40,7 @@ def list_assets(
|
||||
) -> ListAssetsResponse:
|
||||
use_case = ListAssetsUseCase(asset_repository)
|
||||
items = use_case.execute(library_id)
|
||||
return ListAssetsResponse(
|
||||
items=[
|
||||
AssetResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
library_id=item.library_id,
|
||||
name=item.name,
|
||||
storage_key=item.storage_key,
|
||||
mime_type=item.mime_type,
|
||||
metadata=item.metadata,
|
||||
)
|
||||
for item in items
|
||||
]
|
||||
)
|
||||
return ListAssetsResponse(items=[_to_asset_response(item) for item in items])
|
||||
|
||||
|
||||
@router.post("", response_model=AssetResponse)
|
||||
@@ -47,15 +58,17 @@ def create_asset(
|
||||
storage_key=request.storage_key,
|
||||
mime_type=request.mime_type,
|
||||
metadata=request.metadata,
|
||||
file_size=request.file_size,
|
||||
thumbnail_url=request.thumbnail_url,
|
||||
duration=request.duration,
|
||||
width=request.width,
|
||||
height=request.height,
|
||||
fps=request.fps,
|
||||
codec=request.codec,
|
||||
status=AssetStatus(request.status),
|
||||
classification_status=ClassificationStatus(request.classification_status),
|
||||
quality_score=request.quality_score,
|
||||
uploaded_by_user_id=request.uploaded_by_user_id,
|
||||
)
|
||||
)
|
||||
return AssetResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
library_id=item.library_id,
|
||||
name=item.name,
|
||||
storage_key=item.storage_key,
|
||||
mime_type=item.mime_type,
|
||||
metadata=item.metadata,
|
||||
)
|
||||
return _to_asset_response(item)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.dependencies import get_generated_video_repository
|
||||
from app.schemas.generated_video import GeneratedVideoResponse, ListGeneratedVideosResponse
|
||||
from packages.adapters.sqlalchemy_impl import SQLAlchemyGeneratedVideoRepository
|
||||
from packages.application import GetGeneratedVideoUseCase, ListGeneratedVideosUseCase
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=ListGeneratedVideosResponse)
|
||||
def list_generated_videos(
|
||||
project_id: str,
|
||||
generated_video_repository: SQLAlchemyGeneratedVideoRepository = Depends(get_generated_video_repository),
|
||||
) -> ListGeneratedVideosResponse:
|
||||
use_case = ListGeneratedVideosUseCase(generated_video_repository)
|
||||
items = use_case.execute(project_id)
|
||||
return ListGeneratedVideosResponse(
|
||||
items=[
|
||||
GeneratedVideoResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
generation_task_id=item.generation_task_id,
|
||||
name=item.name,
|
||||
file_url=item.file_url,
|
||||
file_size=item.file_size,
|
||||
duration=item.duration,
|
||||
thumbnail_url=item.thumbnail_url,
|
||||
width=item.width,
|
||||
height=item.height,
|
||||
fps=item.fps,
|
||||
)
|
||||
for item in items
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{video_id}", response_model=GeneratedVideoResponse)
|
||||
def get_generated_video(
|
||||
video_id: str,
|
||||
generated_video_repository: SQLAlchemyGeneratedVideoRepository = Depends(get_generated_video_repository),
|
||||
) -> GeneratedVideoResponse:
|
||||
use_case = GetGeneratedVideoUseCase(generated_video_repository)
|
||||
item = use_case.execute(video_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
|
||||
return GeneratedVideoResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
generation_task_id=item.generation_task_id,
|
||||
name=item.name,
|
||||
file_url=item.file_url,
|
||||
file_size=item.file_size,
|
||||
duration=item.duration,
|
||||
thumbnail_url=item.thumbnail_url,
|
||||
width=item.width,
|
||||
height=item.height,
|
||||
fps=item.fps,
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.dependencies import get_generation_task_repository, get_generated_video_repository
|
||||
from app.schemas.generation_task import CreateGenerationTaskRequest, GenerationTaskResponse
|
||||
from app.schemas.generated_video import GeneratedVideoResponse, ListGeneratedVideosResponse
|
||||
from packages.adapters.sqlalchemy_impl import SQLAlchemyGeneratedVideoRepository, SQLAlchemyGenerationTaskRepository
|
||||
from packages.application import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
GetGenerationTaskUseCase,
|
||||
ListGeneratedVideosByTaskUseCase,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/tasks", response_model=GenerationTaskResponse)
|
||||
def create_generation_task(
|
||||
request: CreateGenerationTaskRequest,
|
||||
generation_task_repository: SQLAlchemyGenerationTaskRepository = Depends(get_generation_task_repository),
|
||||
) -> GenerationTaskResponse:
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
workspace_id=request.workspace_id,
|
||||
project_id=request.project_id,
|
||||
asset_library_id=request.asset_library_id,
|
||||
strategy_id=request.strategy_id,
|
||||
voice_library_id=request.voice_library_id,
|
||||
created_by_user_id=request.created_by_user_id,
|
||||
)
|
||||
)
|
||||
return GenerationTaskResponse(
|
||||
id=task.id,
|
||||
workspace_id=task.workspace_id,
|
||||
project_id=task.project_id,
|
||||
asset_library_id=task.asset_library_id,
|
||||
strategy_id=task.strategy_id,
|
||||
voice_library_id=task.voice_library_id,
|
||||
status=task.status.value,
|
||||
progress=task.progress,
|
||||
result_count=task.result_count,
|
||||
error_message=task.error_message,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}", response_model=GenerationTaskResponse)
|
||||
def get_generation_task(
|
||||
task_id: str,
|
||||
generation_task_repository: SQLAlchemyGenerationTaskRepository = Depends(get_generation_task_repository),
|
||||
) -> GenerationTaskResponse:
|
||||
use_case = GetGenerationTaskUseCase(generation_task_repository)
|
||||
task = use_case.execute(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
|
||||
return GenerationTaskResponse(
|
||||
id=task.id,
|
||||
workspace_id=task.workspace_id,
|
||||
project_id=task.project_id,
|
||||
asset_library_id=task.asset_library_id,
|
||||
strategy_id=task.strategy_id,
|
||||
voice_library_id=task.voice_library_id,
|
||||
status=task.status.value,
|
||||
progress=task.progress,
|
||||
result_count=task.result_count,
|
||||
error_message=task.error_message,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/results", response_model=ListGeneratedVideosResponse)
|
||||
def list_generation_results(
|
||||
task_id: str,
|
||||
generated_video_repository: SQLAlchemyGeneratedVideoRepository = Depends(get_generated_video_repository),
|
||||
) -> ListGeneratedVideosResponse:
|
||||
use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
|
||||
items = use_case.execute(task_id)
|
||||
return ListGeneratedVideosResponse(
|
||||
items=[
|
||||
GeneratedVideoResponse(
|
||||
id=item.id,
|
||||
workspace_id=item.workspace_id,
|
||||
project_id=item.project_id,
|
||||
generation_task_id=item.generation_task_id,
|
||||
name=item.name,
|
||||
file_url=item.file_url,
|
||||
file_size=item.file_size,
|
||||
duration=item.duration,
|
||||
thumbnail_url=item.thumbnail_url,
|
||||
width=item.width,
|
||||
height=item.height,
|
||||
fps=item.fps,
|
||||
)
|
||||
for item in items
|
||||
]
|
||||
)
|
||||
@@ -5,6 +5,8 @@ from app.config import settings
|
||||
from packages.adapters.sqlalchemy_impl.asset_library_repository import SQLAlchemyAssetLibraryRepository
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.adapters.sqlalchemy_impl.classification_job_repository import SQLAlchemyClassificationJobRepository
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import SQLAlchemyGeneratedVideoRepository
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import SQLAlchemyGenerationTaskRepository
|
||||
from packages.adapters.sqlalchemy_impl.ingest_job_repository import SQLAlchemyIngestJobRepository
|
||||
from packages.adapters.sqlalchemy_impl.project_repository import SQLAlchemyProjectRepository
|
||||
from packages.adapters.sqlalchemy_impl.session import build_session_factory
|
||||
@@ -36,5 +38,13 @@ def get_classification_job_repository(session: Session = Depends(get_db_session)
|
||||
return SQLAlchemyClassificationJobRepository(session)
|
||||
|
||||
|
||||
def get_generation_task_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyGenerationTaskRepository:
|
||||
return SQLAlchemyGenerationTaskRepository(session)
|
||||
|
||||
|
||||
def get_generated_video_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyGeneratedVideoRepository:
|
||||
return SQLAlchemyGeneratedVideoRepository(session)
|
||||
|
||||
|
||||
def get_project_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyProjectRepository:
|
||||
return SQLAlchemyProjectRepository(session)
|
||||
|
||||
@@ -9,6 +9,17 @@ class CreateAssetRequest(BaseModel):
|
||||
storage_key: str = Field(..., min_length=1, max_length=255)
|
||||
mime_type: str = Field(..., min_length=1, max_length=100)
|
||||
metadata: dict[str, object] = Field(default_factory=dict)
|
||||
file_size: int = Field(default=0, ge=0)
|
||||
thumbnail_url: str | None = None
|
||||
duration: float | None = Field(default=None, ge=0)
|
||||
width: int | None = Field(default=None, ge=0)
|
||||
height: int | None = Field(default=None, ge=0)
|
||||
fps: float | None = Field(default=None, ge=0)
|
||||
codec: str | None = None
|
||||
status: str = Field(default="uploading")
|
||||
classification_status: str = Field(default="pending")
|
||||
quality_score: float | None = Field(default=None, ge=0, le=100)
|
||||
uploaded_by_user_id: str = Field(default="", max_length=100)
|
||||
|
||||
|
||||
class AssetResponse(BaseModel):
|
||||
@@ -20,6 +31,17 @@ class AssetResponse(BaseModel):
|
||||
storage_key: str
|
||||
mime_type: str
|
||||
metadata: dict[str, object]
|
||||
file_size: int
|
||||
thumbnail_url: str | None = None
|
||||
duration: float | None = None
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
fps: float | None = None
|
||||
codec: str | None = None
|
||||
status: str
|
||||
classification_status: str
|
||||
quality_score: float | None = None
|
||||
uploaded_by_user_id: str
|
||||
|
||||
|
||||
class ListAssetsResponse(BaseModel):
|
||||
|
||||
@@ -5,7 +5,7 @@ class CreateAssetLibraryRequest(BaseModel):
|
||||
workspace_id: str = Field(..., min_length=1)
|
||||
project_id: str = Field(..., min_length=1)
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
kind: str = Field(..., pattern="^(video|voice)$")
|
||||
kind: str = Field(..., pattern="^(video|voice|image)$")
|
||||
|
||||
|
||||
class AssetLibraryResponse(BaseModel):
|
||||
@@ -14,6 +14,8 @@ class AssetLibraryResponse(BaseModel):
|
||||
project_id: str
|
||||
name: str
|
||||
kind: str
|
||||
asset_count: int
|
||||
total_size: int
|
||||
|
||||
|
||||
class ListAssetLibrariesResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class GeneratedVideoResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
generation_task_id: str
|
||||
name: str
|
||||
file_url: str
|
||||
file_size: int
|
||||
duration: float
|
||||
thumbnail_url: str | None = None
|
||||
width: int
|
||||
height: int
|
||||
fps: float
|
||||
|
||||
|
||||
class ListGeneratedVideosResponse(BaseModel):
|
||||
items: list[GeneratedVideoResponse]
|
||||
@@ -0,0 +1,23 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateGenerationTaskRequest(BaseModel):
|
||||
workspace_id: str = Field(..., min_length=1)
|
||||
project_id: str = Field(..., min_length=1)
|
||||
asset_library_id: str = Field(..., min_length=1)
|
||||
strategy_id: str = ""
|
||||
voice_library_id: str = ""
|
||||
created_by_user_id: str = ""
|
||||
|
||||
|
||||
class GenerationTaskResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
asset_library_id: str
|
||||
strategy_id: str
|
||||
voice_library_id: str
|
||||
status: str
|
||||
progress: float
|
||||
result_count: int
|
||||
error_message: str
|
||||
+40
-146
@@ -1,171 +1,65 @@
|
||||
from datetime import datetime, timezone
|
||||
import random
|
||||
|
||||
from app.config import get_settings
|
||||
from .celery_app import celery_app
|
||||
from packages.adapters.sqlalchemy_impl.session import SessionLocal, build_session_factory
|
||||
from packages.adapters.sqlalchemy_impl.ingest_job_repository import SQLAlchemyIngestJobRepository
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.adapters.sqlalchemy_impl.classification_job_repository import SQLAlchemyClassificationJobRepository
|
||||
from packages.domain import Asset, AssetClassification, ClassificationJob, ClassificationJobStatus, IngestJobStatus
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import SQLAlchemyGenerationTaskRepository
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import SQLAlchemyGeneratedVideoRepository
|
||||
from packages.domain import GeneratedVideo, GenerationTaskStatus
|
||||
|
||||
settings = get_settings()
|
||||
if SessionLocal is None:
|
||||
build_session_factory(settings.database_url)
|
||||
|
||||
|
||||
@celery_app.task(name="worker.healthcheck")
|
||||
def healthcheck() -> dict:
|
||||
return {"ok": True, "service": "worker"}
|
||||
|
||||
|
||||
@celery_app.task(name="worker.ingest_asset")
|
||||
def ingest_asset(job_id: str) -> dict:
|
||||
@celery_app.task(name="worker.generate_video")
|
||||
def generate_video(task_id: str) -> dict:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
ingest_repo = SQLAlchemyIngestJobRepository(session)
|
||||
asset_repo = SQLAlchemyAssetRepository(session)
|
||||
classification_repo = SQLAlchemyClassificationJobRepository(session)
|
||||
task_repo = SQLAlchemyGenerationTaskRepository(session)
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
|
||||
job = ingest_repo.get(job_id)
|
||||
if job is None:
|
||||
return {"ok": False, "error": f"job {job_id} not found"}
|
||||
task = task_repo.get(task_id)
|
||||
if task is None:
|
||||
return {"ok": False, "error": f"generation task {task_id} not found"}
|
||||
|
||||
job.status = IngestJobStatus.PROCESSING
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
ingest_repo.update(job)
|
||||
task.status = GenerationTaskStatus.RUNNING
|
||||
task.progress = 10.0
|
||||
task.started_at = task.started_at or datetime.now(timezone.utc)
|
||||
task_repo.update(task)
|
||||
|
||||
storage_key = job.storage_key
|
||||
filename = storage_key.split("/")[-1]
|
||||
lower_name = filename.lower()
|
||||
if lower_name.endswith((".mp4", ".mov", ".avi", ".mkv")):
|
||||
mime_type = "video/mp4"
|
||||
elif lower_name.endswith((".mp3", ".wav", ".aac")):
|
||||
mime_type = "audio/mpeg"
|
||||
elif lower_name.endswith((".jpg", ".jpeg")):
|
||||
mime_type = "image/jpeg"
|
||||
elif lower_name.endswith((".png", ".webp", ".gif")):
|
||||
mime_type = "image/png"
|
||||
else:
|
||||
mime_type = "application/octet-stream"
|
||||
|
||||
asset = Asset.create(
|
||||
workspace_id=job.workspace_id,
|
||||
project_id=job.project_id,
|
||||
library_id=job.library_id,
|
||||
name=filename,
|
||||
storage_key=storage_key,
|
||||
mime_type=mime_type,
|
||||
metadata={"source": "ingest_task", "auto_classification": "queued"},
|
||||
video = GeneratedVideo.create(
|
||||
workspace_id=task.workspace_id,
|
||||
project_id=task.project_id,
|
||||
generation_task_id=task.id,
|
||||
name=f"{task.id}.mp4",
|
||||
file_url=f"https://example.invalid/generated/{task.id}.mp4",
|
||||
file_size=1024,
|
||||
duration=10.0,
|
||||
width=1920,
|
||||
height=1080,
|
||||
fps=25.0,
|
||||
)
|
||||
asset_repo.create(asset)
|
||||
video_repo.create(video)
|
||||
|
||||
job.status = IngestJobStatus.COMPLETED
|
||||
job.result_asset_id = asset.id
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
ingest_repo.update(job)
|
||||
task.status = GenerationTaskStatus.COMPLETED
|
||||
task.progress = 100.0
|
||||
task.result_count = 1
|
||||
task.completed_at = datetime.now(timezone.utc)
|
||||
task_repo.update(task)
|
||||
|
||||
classification_job = ClassificationJob.create(
|
||||
workspace_id=job.workspace_id,
|
||||
project_id=job.project_id,
|
||||
asset_id=asset.id,
|
||||
)
|
||||
classification_repo.create(classification_job)
|
||||
celery_app.send_task("worker.classify_asset", args=[classification_job.id])
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"job_id": job.id,
|
||||
"asset_id": asset.id,
|
||||
"classification_job_id": classification_job.id,
|
||||
}
|
||||
return {"ok": True, "task_id": task.id, "video_id": video.id}
|
||||
except Exception as error:
|
||||
try:
|
||||
ingest_repo = SQLAlchemyIngestJobRepository(session)
|
||||
job = ingest_repo.get(job_id)
|
||||
if job is not None:
|
||||
job.status = IngestJobStatus.FAILED
|
||||
job.error_message = str(error)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
ingest_repo.update(job)
|
||||
task_repo = SQLAlchemyGenerationTaskRepository(session)
|
||||
task = task_repo.get(task_id)
|
||||
if task is not None:
|
||||
task.status = GenerationTaskStatus.FAILED
|
||||
task.error_message = str(error)
|
||||
task.completed_at = datetime.now(timezone.utc)
|
||||
task_repo.update(task)
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": False, "job_id": job_id, "error": str(error)}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@celery_app.task(name="worker.classify_asset")
|
||||
def classify_asset(job_id: str) -> dict:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
classification_repo = SQLAlchemyClassificationJobRepository(session)
|
||||
asset_repo = SQLAlchemyAssetRepository(session)
|
||||
|
||||
job = classification_repo.get(job_id)
|
||||
if job is None:
|
||||
return {"ok": False, "error": f"classification job {job_id} not found"}
|
||||
|
||||
job.status = ClassificationJobStatus.PROCESSING
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
classification_repo.update(job)
|
||||
|
||||
asset = asset_repo.get(job.asset_id)
|
||||
if asset is None:
|
||||
raise ValueError(f"asset {job.asset_id} not found")
|
||||
|
||||
name = asset.name.lower()
|
||||
if any(token in name for token in ["food", "meal", "cook"]):
|
||||
classification = AssetClassification.FOOD.value
|
||||
elif any(token in name for token in ["person", "human", "portrait"]):
|
||||
classification = AssetClassification.PERSON.value
|
||||
elif any(token in name for token in ["music", "song", "audio"]):
|
||||
classification = AssetClassification.MUSIC.value
|
||||
elif any(token in name for token in ["product", "sku", "item"]):
|
||||
classification = AssetClassification.PRODUCT.value
|
||||
elif any(token in name for token in ["animal", "pet", "cat", "dog"]):
|
||||
classification = AssetClassification.ANIMAL.value
|
||||
elif any(token in name for token in ["sport", "run", "ball"]):
|
||||
classification = AssetClassification.SPORT.value
|
||||
elif any(token in name for token in ["tech", "phone", "device", "pc"]):
|
||||
classification = AssetClassification.TECH.value
|
||||
elif any(token in name for token in ["view", "travel", "mountain", "sea"]):
|
||||
classification = AssetClassification.SCENIC.value
|
||||
else:
|
||||
classification = AssetClassification.OTHER.value
|
||||
|
||||
confidence = round(random.uniform(0.72, 0.96), 2)
|
||||
asset.metadata = {
|
||||
**asset.metadata,
|
||||
"classification": classification,
|
||||
"classification_confidence": confidence,
|
||||
"auto_classification": "completed",
|
||||
}
|
||||
asset_repo.update(asset)
|
||||
|
||||
job.status = ClassificationJobStatus.COMPLETED
|
||||
job.classification = classification
|
||||
job.confidence = confidence
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
classification_repo.update(job)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"job_id": job.id,
|
||||
"asset_id": asset.id,
|
||||
"classification": classification,
|
||||
}
|
||||
except Exception as error:
|
||||
try:
|
||||
classification_repo = SQLAlchemyClassificationJobRepository(session)
|
||||
job = classification_repo.get(job_id)
|
||||
if job is not None:
|
||||
job.status = ClassificationJobStatus.FAILED
|
||||
job.error_message = str(error)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
classification_repo.update(job)
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": False, "job_id": job_id, "error": str(error)}
|
||||
return {"ok": False, "task_id": task_id, "error": str(error)}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
# PHASE7-PROGRESS.md
|
||||
|
||||
**Phase**: Phase 7 - 核心视频剪辑业务
|
||||
**状态**: 🔄 进行中
|
||||
**最后更新**: 2026-06-18 19:56 GMT+8
|
||||
|
||||
---
|
||||
|
||||
## 一、Phase 目标
|
||||
|
||||
根据 `F:\openclaw-saas\docs\PHASE7-DESIGN.md`,Phase 7 的目标是打通从:
|
||||
- 上传素材
|
||||
- 素材分类
|
||||
- 发起生成
|
||||
- 查看并下载成片
|
||||
|
||||
即完成 SaaS MVP 的核心视频剪辑主链路。
|
||||
|
||||
---
|
||||
|
||||
## 二、当前实际进展
|
||||
|
||||
### 1. 已完成(基础体系)
|
||||
- [x] 完整工业化开发体系文档已建立
|
||||
- [x] 8 Agent 角色体系已定义
|
||||
- [x] Git 工作流规范已确定
|
||||
- [x] Gitea Runner 已运行
|
||||
- [x] `.gitea/workflows/ci-cd.yml` 已建立
|
||||
- [x] `.github/workflows/ci-cd.yml` 已与 `.gitea` 统一
|
||||
- [x] 开发环境防跑偏收敛方案已建立
|
||||
- [x] 启动链文档已修正到新标准
|
||||
- [x] CI 主链已成功跑通一次(Code Quality / Run Tests / Build Summary)
|
||||
- [x] `requirements-dev.txt` 已接入 CI
|
||||
- [x] 本地 Git 仓库维护已完成第一轮收口(pack 数量已显著下降)
|
||||
|
||||
### 2. 当前进行中(执行策略已调整)
|
||||
- [ ] 文档维护机制持续执行
|
||||
- [ ] 暂停 8 Agent 实跑,保留全部开发规则与质量门禁
|
||||
- [ ] 由主会话按既定文档体系直接推进 Phase 7 业务开发
|
||||
|
||||
### 3. Phase 7 已正式进入第一批业务实现
|
||||
当前优先顺序:
|
||||
- [x] Asset / AssetLibrary 领域模型第一轮收口与校准
|
||||
- [x] 当前主线 `application + api + sqlalchemy_impl + in_memory` 已对齐到统一素材模型
|
||||
- [x] 旧素材链第一轮兼容压平(`domain/asset.py`、`domain/asset_library.py`、旧 `ports`、`postgres/asset_repository.py`)
|
||||
- [x] OSS 上传能力第一轮打通
|
||||
- [x] 素材列表与查询流程第一轮打通
|
||||
- [x] ClassificationJob 流程第一轮打通
|
||||
- [x] GenerationTask 主线骨架已落地
|
||||
- [x] GeneratedVideo 主线骨架已落地
|
||||
- [ ] 前端主链路联调完成
|
||||
|
||||
### 4. 本轮已完成的具体验证
|
||||
- [x] `tests/integration/test_asset_tags.py` 通过
|
||||
- [x] `tests/integration/test_ingest_pipeline.py` 通过
|
||||
- [x] `tests/integration/test_upload_pipeline.py` 通过
|
||||
- [x] `tests/integration/test_classification_pipeline.py` 通过
|
||||
- [x] `tests/integration/test_projects.py` 通过
|
||||
- [x] `tests/integration/test_generation_pipeline.py` 通过
|
||||
- [x] 素材与生成主线相关目录编译检查通过
|
||||
|
||||
---
|
||||
|
||||
## 三、当前阻塞点
|
||||
|
||||
### 阻塞点 A:OpenClaw 子 Agent 运行时暂不可作为正式执行底座
|
||||
已确认当前 `webchat/control-ui` 链路下,子 Agent 存在 runtime / continuation 异常。
|
||||
当前决策:**暂停 Agent 实跑,不让该问题阻塞 SaaS 主线开发**。
|
||||
|
||||
### 阻塞点 B:文档维护必须持续执行
|
||||
文档现在已经补齐,但后续如果不随着真实进展更新,依然会重新变成摆设。
|
||||
必须把更新动作视为开发流程的一部分,而不是事后补写。
|
||||
|
||||
### 阻塞点 C:数据库模型字段命名仍保留历史包袱
|
||||
当前 `sqlalchemy_impl.models.AssetModel` 及相关存储层字段仍使用历史命名:
|
||||
- `asset_library_id`
|
||||
- `file_type`
|
||||
- `file_url`
|
||||
- `classification_result`
|
||||
|
||||
当前处理策略:**先在领域层与适配层完成语义收口,通过映射兼容;数据库层字段重命名延后为独立收口任务,避免影响当前主链开发速度。**
|
||||
|
||||
---
|
||||
|
||||
## 四、下一步顺序(强制)
|
||||
|
||||
### Step 1:文档维护持续化
|
||||
- [ ] 每次状态变化后同步更新 `PHASE7-PROGRESS.md`
|
||||
- [ ] 每次规则变化后同步更新相关规范文档
|
||||
- [ ] 每次新会话启动时先检查路径有效性
|
||||
|
||||
### Step 2:主会话直接推进 Phase 7 第一批业务任务
|
||||
- [x] Asset / AssetLibrary 领域模型与仓储接口第一轮收口
|
||||
- [x] 上传 / Asset 创建链路第一轮对齐
|
||||
- [x] ClassificationJob 与 Asset 元数据更新链路第一轮打通
|
||||
- [x] GenerationTask / GeneratedVideo 主线骨架已补齐
|
||||
- [ ] API / Adapter / Worker 的生成结果流继续联调
|
||||
- [ ] 测试补齐与回归验证
|
||||
|
||||
### Step 3:保留 Agent 体系设计,等待 runtime 修复后再恢复实跑
|
||||
- [ ] 记录 Agent runtime 阻塞结论
|
||||
- [ ] 后续在不影响业务主线时继续修复 OpenClaw 子 Agent 问题
|
||||
|
||||
---
|
||||
|
||||
## 五、当前判断
|
||||
|
||||
**当前 Phase 7 已完成素材前半主链打通,并补出生成链主线骨架,整体仍保持在既定规则内推进。**
|
||||
|
||||
当前执行策略是:
|
||||
- 暂停 Agent 实跑
|
||||
- 保留全部开发规范、文档链、Git/CI/环境规则
|
||||
- 先在本地完成可提交单元收口与聚焦验证
|
||||
- 现在进入提交、推送、CI/CD 完整门禁阶段
|
||||
|
||||
---
|
||||
|
||||
## 六、老大可验证问题
|
||||
|
||||
如果新会话启动后问:
|
||||
|
||||
1. **当前 Phase 是什么?**
|
||||
- 答:Phase 7 - 核心视频剪辑业务
|
||||
|
||||
2. **当前 Phase 主要在做什么?**
|
||||
- 答:已切入 Phase 7 第一批业务开发,当前已完成素材前半主链收口,并补出生成链主线骨架,正在进入提交与 CI/CD 验证阶段
|
||||
|
||||
3. **当前最重要的阻塞点是什么?**
|
||||
- 答:OpenClaw 子 Agent runtime 暂不稳定,因此暂停 Agent 实跑;另外数据库字段命名仍有历史包袱,但已通过映射兼容,不阻断主线开发
|
||||
|
||||
4. **当前进度文档路径是什么?**
|
||||
- 答:`F:\openclaw-saas\docs\PHASE7-PROGRESS.md`
|
||||
|
||||
---
|
||||
|
||||
## 七、更新规则
|
||||
|
||||
每次发生以下情况,必须更新本文件:
|
||||
- CI 环境策略发生变化
|
||||
- Phase 7 正式进入业务实现
|
||||
- 新的关键阻塞点出现
|
||||
- 一个业务里程碑完成
|
||||
- 创建或试跑新的 Agent
|
||||
- 规则文档路径发生变化
|
||||
|
||||
### 最低更新要求(强制)
|
||||
- 每次完成一个阶段性收口动作,必须更新一次
|
||||
- 每次会话结束前,若状态有变化,必须检查是否需要更新
|
||||
- 如果文档状态落后于真实状态,视为违反流程,不得继续推进新任务
|
||||
|
||||
---
|
||||
|
||||
**状态结论**:Phase 7 未跑偏,已暂停 Agent 实跑并切回主会话直开;当前素材前半主链已打通,生成链主线骨架已落地,现进入提交与 CI/CD 验证阶段。
|
||||
@@ -1,73 +1,43 @@
|
||||
"""
|
||||
AssetLibrary InMemory Repository 实现
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from packages.ports.asset_library_repository import AssetLibraryRepository
|
||||
from packages.domain.asset_library import AssetLibrary, LibraryKind
|
||||
"""AssetLibrary InMemory Repository 实现"""
|
||||
from packages.domain import AssetLibrary, AssetLibraryKind
|
||||
|
||||
|
||||
class InMemoryAssetLibraryRepository(AssetLibraryRepository):
|
||||
"""素材库 InMemory 仓储实现(用于测试)"""
|
||||
class InMemoryAssetLibraryRepository:
|
||||
"""素材库 InMemory 仓储实现(用于同步用例和测试)"""
|
||||
|
||||
def __init__(self):
|
||||
self._libraries: dict[str, AssetLibrary] = {}
|
||||
|
||||
async def create(self, library: AssetLibrary) -> AssetLibrary:
|
||||
"""创建素材库"""
|
||||
def create(self, library: AssetLibrary) -> AssetLibrary:
|
||||
self._libraries[library.id] = library
|
||||
return library
|
||||
|
||||
async def find_by_id(self, library_id: str) -> Optional[AssetLibrary]:
|
||||
"""根据 ID 查询素材库"""
|
||||
def get(self, library_id: str) -> AssetLibrary | None:
|
||||
return self._libraries.get(library_id)
|
||||
|
||||
async def find_by_workspace(
|
||||
self,
|
||||
workspace_id: str,
|
||||
kind: Optional[LibraryKind] = None,
|
||||
) -> List[AssetLibrary]:
|
||||
"""根据工作空间查询素材库"""
|
||||
libraries = [
|
||||
lib for lib in self._libraries.values()
|
||||
if lib.workspace_id == workspace_id
|
||||
]
|
||||
if kind:
|
||||
libraries = [lib for lib in libraries if lib.kind == kind]
|
||||
return libraries
|
||||
def list_by_project(self, project_id: str, kind: AssetLibraryKind | None = None) -> list[AssetLibrary]:
|
||||
items = [library for library in self._libraries.values() if library.project_id == project_id]
|
||||
if kind is not None:
|
||||
items = [library for library in items if library.kind == kind]
|
||||
return items
|
||||
|
||||
async def find_by_project(
|
||||
self,
|
||||
project_id: str,
|
||||
workspace_id: str,
|
||||
) -> List[AssetLibrary]:
|
||||
"""根据项目查询素材库"""
|
||||
return [
|
||||
lib for lib in self._libraries.values()
|
||||
if lib.project_id == project_id and lib.workspace_id == workspace_id
|
||||
]
|
||||
|
||||
async def update(self, library: AssetLibrary) -> AssetLibrary:
|
||||
"""更新素材库"""
|
||||
def update(self, library: AssetLibrary) -> AssetLibrary:
|
||||
self._libraries[library.id] = library
|
||||
return library
|
||||
|
||||
async def delete(self, library_id: str, workspace_id: str) -> bool:
|
||||
"""删除素材库"""
|
||||
library = self._libraries.get(library_id)
|
||||
if library and library.workspace_id == workspace_id:
|
||||
def delete(self, library_id: str) -> bool:
|
||||
if library_id in self._libraries:
|
||||
del self._libraries[library_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
async def increment_asset_count(self, library_id: str, size_delta: int) -> None:
|
||||
"""增加素材数量和大小"""
|
||||
def increment_asset_count(self, library_id: str, size_delta: int) -> None:
|
||||
library = self._libraries.get(library_id)
|
||||
if library:
|
||||
library.asset_count += 1
|
||||
library.total_size += size_delta
|
||||
|
||||
async def decrement_asset_count(self, library_id: str, size_delta: int) -> None:
|
||||
"""减少素材数量和大小"""
|
||||
def decrement_asset_count(self, library_id: str, size_delta: int) -> None:
|
||||
library = self._libraries.get(library_id)
|
||||
if library:
|
||||
library.asset_count = max(0, library.asset_count - 1)
|
||||
|
||||
@@ -1,70 +1,32 @@
|
||||
"""
|
||||
Asset InMemory Repository 实现
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from packages.ports.asset_repository import AssetRepository
|
||||
from packages.domain.asset import Asset
|
||||
"""Asset InMemory Repository 实现"""
|
||||
from packages.domain import Asset
|
||||
|
||||
|
||||
class InMemoryAssetRepository(AssetRepository):
|
||||
"""素材 InMemory 仓储实现(用于测试)"""
|
||||
class InMemoryAssetRepository:
|
||||
"""素材 InMemory 仓储实现(用于同步用例和测试)"""
|
||||
|
||||
def __init__(self):
|
||||
self._assets: dict[str, Asset] = {}
|
||||
|
||||
async def create(self, asset: Asset) -> Asset:
|
||||
"""创建素材"""
|
||||
def create(self, asset: Asset) -> Asset:
|
||||
self._assets[asset.id] = asset
|
||||
return asset
|
||||
|
||||
async def find_by_id(self, asset_id: str) -> Optional[Asset]:
|
||||
"""根据 ID 查询素材"""
|
||||
def get(self, asset_id: str) -> Asset | None:
|
||||
return self._assets.get(asset_id)
|
||||
|
||||
async def find_by_project(
|
||||
self,
|
||||
project_id: str,
|
||||
workspace_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> List[Asset]:
|
||||
"""根据项目查询素材列表"""
|
||||
assets = [
|
||||
asset for asset in self._assets.values()
|
||||
if asset.project_id == project_id and asset.workspace_id == workspace_id
|
||||
]
|
||||
return assets[skip:skip + limit]
|
||||
def list_by_project(self, project_id: str) -> list[Asset]:
|
||||
return [asset for asset in self._assets.values() if asset.project_id == project_id]
|
||||
|
||||
async def find_by_library(
|
||||
self,
|
||||
library_id: str,
|
||||
workspace_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> List[Asset]:
|
||||
"""根据素材库查询素材列表"""
|
||||
assets = [
|
||||
asset for asset in self._assets.values()
|
||||
if asset.asset_library_id == library_id and asset.workspace_id == workspace_id
|
||||
]
|
||||
return assets[skip:skip + limit]
|
||||
def list_by_library(self, library_id: str) -> list[Asset]:
|
||||
return [asset for asset in self._assets.values() if asset.library_id == library_id]
|
||||
|
||||
async def update(self, asset: Asset) -> Asset:
|
||||
"""更新素材"""
|
||||
def update(self, asset: Asset) -> Asset:
|
||||
self._assets[asset.id] = asset
|
||||
return asset
|
||||
|
||||
async def delete(self, asset_id: str, workspace_id: str) -> bool:
|
||||
"""删除素材"""
|
||||
asset = self._assets.get(asset_id)
|
||||
if asset and asset.workspace_id == workspace_id:
|
||||
def delete(self, asset_id: str) -> bool:
|
||||
if asset_id in self._assets:
|
||||
del self._assets[asset_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
async def count_by_project(self, project_id: str, workspace_id: str) -> int:
|
||||
"""统计项目素材数量"""
|
||||
return len([
|
||||
asset for asset in self._assets.values()
|
||||
if asset.project_id == project_id and asset.workspace_id == workspace_id
|
||||
])
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"""
|
||||
PostgreSQL 适配器
|
||||
"""
|
||||
from packages.adapters.postgres.user_repository import PostgresUserRepository
|
||||
from packages.adapters.postgres.workspace_repository import PostgresWorkspaceRepository
|
||||
from packages.adapters.postgres.workspace_member_repository import PostgresWorkspaceMemberRepository
|
||||
from packages.adapters.postgres.workspace_invitation_repository import PostgresWorkspaceInvitationRepository
|
||||
from packages.adapters.postgres.asset_repository import PostgresAssetRepository
|
||||
from packages.adapters.postgres.project_repository import PostgresProjectRepository
|
||||
from packages.adapters.postgres.user_repository import PostgresUserRepository
|
||||
from packages.adapters.postgres.workspace_invitation_repository import PostgresWorkspaceInvitationRepository
|
||||
from packages.adapters.postgres.workspace_member_repository import PostgresWorkspaceMemberRepository
|
||||
from packages.adapters.postgres.workspace_repository import PostgresWorkspaceRepository
|
||||
|
||||
__all__ = [
|
||||
"PostgresAssetRepository",
|
||||
"PostgresUserRepository",
|
||||
"PostgresWorkspaceRepository",
|
||||
"PostgresWorkspaceMemberRepository",
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
"""
|
||||
Asset PostgreSQL Repository 实现
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import select, and_, func
|
||||
import json
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from packages.ports.asset_repository import AssetRepository
|
||||
from packages.domain.asset import Asset, AssetType, AssetStatus, ClassificationStatus
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
from packages.domain import Asset, AssetStatus, ClassificationStatus
|
||||
from packages.ports.asset_repository import AssetRepository
|
||||
|
||||
|
||||
class PostgresAssetRepository(AssetRepository):
|
||||
"""素材 PostgreSQL 仓储实现"""
|
||||
"""遗留异步 PostgreSQL 素材仓储,已对齐当前主线实体字段。"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def create(self, asset: Asset) -> Asset:
|
||||
"""创建素材"""
|
||||
model = AssetModel(
|
||||
id=asset.id,
|
||||
workspace_id=asset.workspace_id,
|
||||
project_id=asset.project_id,
|
||||
asset_library_id=asset.asset_library_id,
|
||||
asset_library_id=asset.library_id,
|
||||
name=asset.name,
|
||||
file_type=asset.file_type.value,
|
||||
file_type=asset.mime_type.split("/")[0] if "/" in asset.mime_type else asset.mime_type,
|
||||
file_size=asset.file_size,
|
||||
file_url=asset.file_url,
|
||||
file_url=asset.storage_key,
|
||||
thumbnail_url=asset.thumbnail_url,
|
||||
duration=asset.duration,
|
||||
width=asset.width,
|
||||
@@ -34,9 +35,9 @@ class PostgresAssetRepository(AssetRepository):
|
||||
codec=asset.codec,
|
||||
status=asset.status.value,
|
||||
classification_status=asset.classification_status.value,
|
||||
classification_result=asset.classification_result,
|
||||
classification_result=json.dumps(asset.metadata) if asset.metadata else None,
|
||||
quality_score=asset.quality_score,
|
||||
uploaded_by_user_id=asset.uploaded_by_user_id,
|
||||
uploaded_by_user_id=asset.uploaded_by_user_id or "system",
|
||||
created_at=asset.created_at,
|
||||
updated_at=asset.updated_at,
|
||||
)
|
||||
@@ -44,11 +45,8 @@ class PostgresAssetRepository(AssetRepository):
|
||||
await self.session.flush()
|
||||
return asset
|
||||
|
||||
async def find_by_id(self, asset_id: str) -> Optional[Asset]:
|
||||
"""根据 ID 查询素材"""
|
||||
result = await self.session.execute(
|
||||
select(AssetModel).where(AssetModel.id == asset_id)
|
||||
)
|
||||
async def find_by_id(self, asset_id: str) -> Asset | None:
|
||||
result = await self.session.execute(select(AssetModel).where(AssetModel.id == asset_id))
|
||||
model = result.scalar_one_or_none()
|
||||
return self._to_entity(model) if model else None
|
||||
|
||||
@@ -58,16 +56,10 @@ class PostgresAssetRepository(AssetRepository):
|
||||
workspace_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> List[Asset]:
|
||||
"""根据项目查询素材列表"""
|
||||
) -> list[Asset]:
|
||||
result = await self.session.execute(
|
||||
select(AssetModel)
|
||||
.where(
|
||||
and_(
|
||||
AssetModel.project_id == project_id,
|
||||
AssetModel.workspace_id == workspace_id,
|
||||
)
|
||||
)
|
||||
.where(and_(AssetModel.project_id == project_id, AssetModel.workspace_id == workspace_id))
|
||||
.order_by(AssetModel.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
@@ -80,16 +72,10 @@ class PostgresAssetRepository(AssetRepository):
|
||||
workspace_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> List[Asset]:
|
||||
"""根据素材库查询素材列表"""
|
||||
) -> list[Asset]:
|
||||
result = await self.session.execute(
|
||||
select(AssetModel)
|
||||
.where(
|
||||
and_(
|
||||
AssetModel.asset_library_id == library_id,
|
||||
AssetModel.workspace_id == workspace_id,
|
||||
)
|
||||
)
|
||||
.where(and_(AssetModel.asset_library_id == library_id, AssetModel.workspace_id == workspace_id))
|
||||
.order_by(AssetModel.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
@@ -97,36 +83,30 @@ class PostgresAssetRepository(AssetRepository):
|
||||
return [self._to_entity(model) for model in result.scalars().all()]
|
||||
|
||||
async def update(self, asset: Asset) -> Asset:
|
||||
"""更新素材"""
|
||||
result = await self.session.execute(
|
||||
select(AssetModel).where(AssetModel.id == asset.id)
|
||||
)
|
||||
result = await self.session.execute(select(AssetModel).where(AssetModel.id == asset.id))
|
||||
model = result.scalar_one_or_none()
|
||||
if model:
|
||||
model.name = asset.name
|
||||
model.status = asset.status.value
|
||||
model.file_size = asset.file_size
|
||||
model.file_url = asset.storage_key
|
||||
model.thumbnail_url = asset.thumbnail_url
|
||||
model.duration = asset.duration
|
||||
model.width = asset.width
|
||||
model.height = asset.height
|
||||
model.fps = asset.fps
|
||||
model.codec = asset.codec
|
||||
model.status = asset.status.value
|
||||
model.classification_status = asset.classification_status.value
|
||||
model.classification_result = asset.classification_result
|
||||
model.classification_result = json.dumps(asset.metadata) if asset.metadata else None
|
||||
model.quality_score = asset.quality_score
|
||||
model.uploaded_by_user_id = asset.uploaded_by_user_id or model.uploaded_by_user_id
|
||||
model.updated_at = asset.updated_at
|
||||
await self.session.flush()
|
||||
return asset
|
||||
|
||||
async def delete(self, asset_id: str, workspace_id: str) -> bool:
|
||||
"""删除素材"""
|
||||
result = await self.session.execute(
|
||||
select(AssetModel).where(
|
||||
and_(
|
||||
AssetModel.id == asset_id,
|
||||
AssetModel.workspace_id == workspace_id,
|
||||
)
|
||||
)
|
||||
select(AssetModel).where(and_(AssetModel.id == asset_id, AssetModel.workspace_id == workspace_id))
|
||||
)
|
||||
model = result.scalar_one_or_none()
|
||||
if model:
|
||||
@@ -136,28 +116,36 @@ class PostgresAssetRepository(AssetRepository):
|
||||
return False
|
||||
|
||||
async def count_by_project(self, project_id: str, workspace_id: str) -> int:
|
||||
"""统计项目素材数量"""
|
||||
result = await self.session.execute(
|
||||
select(func.count(AssetModel.id)).where(
|
||||
and_(
|
||||
AssetModel.project_id == project_id,
|
||||
AssetModel.workspace_id == workspace_id,
|
||||
)
|
||||
and_(AssetModel.project_id == project_id, AssetModel.workspace_id == workspace_id)
|
||||
)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
def _to_entity(self, model: AssetModel) -> Asset:
|
||||
"""模型转实体"""
|
||||
metadata = {}
|
||||
if model.classification_result:
|
||||
try:
|
||||
metadata = json.loads(model.classification_result)
|
||||
except Exception:
|
||||
metadata = {}
|
||||
mime_type = model.file_type
|
||||
if "/" not in mime_type:
|
||||
mime_type = {
|
||||
"video": "video/mp4",
|
||||
"audio": "audio/mpeg",
|
||||
"image": "image/jpeg",
|
||||
}.get(mime_type, mime_type)
|
||||
return Asset(
|
||||
id=model.id,
|
||||
workspace_id=model.workspace_id,
|
||||
project_id=model.project_id,
|
||||
asset_library_id=model.asset_library_id,
|
||||
library_id=model.asset_library_id,
|
||||
name=model.name,
|
||||
file_type=AssetType(model.file_type),
|
||||
file_size=model.file_size,
|
||||
file_url=model.file_url,
|
||||
storage_key=model.file_url,
|
||||
mime_type=mime_type,
|
||||
file_size=int(model.file_size or 0),
|
||||
thumbnail_url=model.thumbnail_url,
|
||||
duration=model.duration,
|
||||
width=model.width,
|
||||
@@ -166,9 +154,9 @@ class PostgresAssetRepository(AssetRepository):
|
||||
codec=model.codec,
|
||||
status=AssetStatus(model.status),
|
||||
classification_status=ClassificationStatus(model.classification_status),
|
||||
classification_result=model.classification_result,
|
||||
quality_score=model.quality_score,
|
||||
uploaded_by_user_id=model.uploaded_by_user_id,
|
||||
metadata=metadata,
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from .asset_library_repository import SQLAlchemyAssetLibraryRepository
|
||||
from .asset_repository import SQLAlchemyAssetRepository
|
||||
from .classification_job_repository import SQLAlchemyClassificationJobRepository
|
||||
from .generated_video_repository import SQLAlchemyGeneratedVideoRepository
|
||||
from .generation_task_repository import SQLAlchemyGenerationTaskRepository
|
||||
from .ingest_job_repository import SQLAlchemyIngestJobRepository
|
||||
from .project_repository import SQLAlchemyProjectRepository
|
||||
from .session import Base, build_engine, build_session_factory, ensure_database_exists, initialize_database
|
||||
@@ -12,6 +14,8 @@ __all__ = [
|
||||
"SQLAlchemyAssetLibraryRepository",
|
||||
"SQLAlchemyAssetRepository",
|
||||
"SQLAlchemyClassificationJobRepository",
|
||||
"SQLAlchemyGeneratedVideoRepository",
|
||||
"SQLAlchemyGenerationTaskRepository",
|
||||
"SQLAlchemyIngestJobRepository",
|
||||
"SQLAlchemyProjectRepository",
|
||||
"build_engine",
|
||||
|
||||
@@ -20,7 +20,10 @@ class SQLAlchemyAssetLibraryRepository:
|
||||
project_id=model.project_id,
|
||||
name=model.name,
|
||||
kind=AssetLibraryKind(model.kind),
|
||||
asset_count=int(model.asset_count or 0),
|
||||
total_size=int(model.total_size or 0),
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
for model in models
|
||||
]
|
||||
@@ -32,7 +35,10 @@ class SQLAlchemyAssetLibraryRepository:
|
||||
project_id=library.project_id,
|
||||
name=library.name,
|
||||
kind=library.kind.value,
|
||||
asset_count=library.asset_count,
|
||||
total_size=library.total_size,
|
||||
created_at=library.created_at,
|
||||
updated_at=library.updated_at,
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
from packages.domain import Asset
|
||||
from packages.domain import Asset, AssetStatus, ClassificationStatus
|
||||
|
||||
|
||||
class SQLAlchemyAssetRepository:
|
||||
@@ -30,14 +30,19 @@ class SQLAlchemyAssetRepository:
|
||||
asset_library_id=asset.library_id,
|
||||
name=asset.name,
|
||||
file_type=asset.mime_type.split('/')[0] if '/' in asset.mime_type else asset.mime_type,
|
||||
file_size=0,
|
||||
file_size=asset.file_size,
|
||||
file_url=asset.storage_key,
|
||||
thumbnail_url=None,
|
||||
status='ready',
|
||||
classification_status='pending',
|
||||
classification_result=json.dumps(asset.metadata),
|
||||
quality_score=None,
|
||||
uploaded_by_user_id='system',
|
||||
thumbnail_url=asset.thumbnail_url,
|
||||
duration=asset.duration,
|
||||
width=asset.width,
|
||||
height=asset.height,
|
||||
fps=asset.fps,
|
||||
codec=asset.codec,
|
||||
status=asset.status.value,
|
||||
classification_status=asset.classification_status.value,
|
||||
classification_result=json.dumps(asset.metadata) if asset.metadata else None,
|
||||
quality_score=asset.quality_score,
|
||||
uploaded_by_user_id=asset.uploaded_by_user_id or 'system',
|
||||
created_at=asset.created_at,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -50,8 +55,19 @@ class SQLAlchemyAssetRepository:
|
||||
if model is None:
|
||||
raise ValueError(f"Asset {asset.id} not found")
|
||||
model.name = asset.name
|
||||
model.classification_result = json.dumps(asset.metadata)
|
||||
model.classification_status = 'completed' if asset.metadata.get('classification') else 'pending'
|
||||
model.file_size = asset.file_size
|
||||
model.file_url = asset.storage_key
|
||||
model.thumbnail_url = asset.thumbnail_url
|
||||
model.duration = asset.duration
|
||||
model.width = asset.width
|
||||
model.height = asset.height
|
||||
model.fps = asset.fps
|
||||
model.codec = asset.codec
|
||||
model.status = asset.status.value
|
||||
model.classification_status = asset.classification_status.value
|
||||
model.classification_result = json.dumps(asset.metadata) if asset.metadata else None
|
||||
model.quality_score = asset.quality_score
|
||||
model.uploaded_by_user_id = asset.uploaded_by_user_id or model.uploaded_by_user_id
|
||||
model.updated_at = datetime.now(timezone.utc)
|
||||
self.session.commit()
|
||||
return asset
|
||||
@@ -78,6 +94,18 @@ class SQLAlchemyAssetRepository:
|
||||
name=model.name,
|
||||
storage_key=model.file_url,
|
||||
mime_type=mime_type,
|
||||
file_size=int(model.file_size or 0),
|
||||
thumbnail_url=model.thumbnail_url,
|
||||
duration=model.duration,
|
||||
width=int(model.width) if model.width is not None else None,
|
||||
height=int(model.height) if model.height is not None else None,
|
||||
fps=model.fps,
|
||||
codec=model.codec,
|
||||
status=AssetStatus(model.status),
|
||||
classification_status=ClassificationStatus(model.classification_status),
|
||||
quality_score=model.quality_score,
|
||||
uploaded_by_user_id=model.uploaded_by_user_id,
|
||||
metadata=metadata,
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
from packages.domain import GeneratedVideo
|
||||
|
||||
|
||||
class SQLAlchemyGeneratedVideoRepository:
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def create(self, video: GeneratedVideo) -> GeneratedVideo:
|
||||
model = GeneratedVideoModel(
|
||||
id=video.id,
|
||||
workspace_id=video.workspace_id,
|
||||
project_id=video.project_id,
|
||||
generation_task_id=video.generation_task_id,
|
||||
name=video.name,
|
||||
file_url=video.file_url,
|
||||
file_size=video.file_size,
|
||||
duration=video.duration,
|
||||
thumbnail_url=video.thumbnail_url,
|
||||
width=video.width,
|
||||
height=video.height,
|
||||
fps=video.fps,
|
||||
generated_at=video.generated_at,
|
||||
created_at=video.created_at,
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return video
|
||||
|
||||
def get(self, video_id: str) -> GeneratedVideo | None:
|
||||
model = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video_id).first()
|
||||
if model is None:
|
||||
return None
|
||||
return GeneratedVideo(
|
||||
id=model.id,
|
||||
workspace_id=model.workspace_id,
|
||||
project_id=model.project_id,
|
||||
generation_task_id=model.generation_task_id,
|
||||
name=model.name,
|
||||
file_url=model.file_url,
|
||||
file_size=int(model.file_size or 0),
|
||||
duration=model.duration,
|
||||
thumbnail_url=model.thumbnail_url,
|
||||
width=int(model.width or 0),
|
||||
height=int(model.height or 0),
|
||||
fps=model.fps,
|
||||
generated_at=model.generated_at,
|
||||
created_at=model.created_at,
|
||||
)
|
||||
|
||||
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]
|
||||
|
||||
def list_by_generation_task(self, generation_task_id: str) -> list[GeneratedVideo]:
|
||||
models = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.generation_task_id == generation_task_id).all()
|
||||
return [self.get(model.id) for model in models if self.get(model.id) is not None]
|
||||
@@ -0,0 +1,68 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GenerationTaskModel
|
||||
from packages.domain import GenerationTask, GenerationTaskStatus
|
||||
|
||||
|
||||
class SQLAlchemyGenerationTaskRepository:
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def create(self, task: GenerationTask) -> GenerationTask:
|
||||
model = GenerationTaskModel(
|
||||
id=task.id,
|
||||
workspace_id=task.workspace_id,
|
||||
project_id=task.project_id,
|
||||
strategy_id=task.strategy_id,
|
||||
asset_library_id=task.asset_library_id,
|
||||
voice_library_id=task.voice_library_id,
|
||||
status=task.status.value,
|
||||
progress=task.progress,
|
||||
result_count=task.result_count,
|
||||
error_message=task.error_message,
|
||||
started_at=task.started_at,
|
||||
completed_at=task.completed_at,
|
||||
created_by_user_id=task.created_by_user_id,
|
||||
created_at=task.created_at,
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return task
|
||||
|
||||
def get(self, task_id: str) -> GenerationTask | None:
|
||||
model = self.session.query(GenerationTaskModel).filter(GenerationTaskModel.id == task_id).first()
|
||||
if model is None:
|
||||
return None
|
||||
return GenerationTask(
|
||||
id=model.id,
|
||||
workspace_id=model.workspace_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=GenerationTaskStatus(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,
|
||||
)
|
||||
|
||||
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]
|
||||
|
||||
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.status = task.status.value
|
||||
model.progress = task.progress
|
||||
model.result_count = task.result_count
|
||||
model.error_message = task.error_message
|
||||
model.started_at = task.started_at
|
||||
model.completed_at = task.completed_at
|
||||
self.session.commit()
|
||||
return task
|
||||
@@ -85,6 +85,44 @@ class ClassificationJobModel(Base):
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class GenerationTaskModel(Base):
|
||||
__tablename__ = "generation_tasks"
|
||||
|
||||
id = Column(String(32), primary_key=True)
|
||||
workspace_id = Column(String(32), nullable=False, index=True)
|
||||
project_id = Column(String(32), nullable=False, index=True)
|
||||
strategy_id = Column(String(32), nullable=False, default="")
|
||||
asset_library_id = Column(String(32), nullable=False, index=True)
|
||||
voice_library_id = Column(String(32), nullable=False, default="")
|
||||
status = Column(String(20), nullable=False, default="pending", index=True)
|
||||
progress = Column(Float, nullable=False, default=0.0)
|
||||
result_count = Column(Float, nullable=False, default=0)
|
||||
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_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class GeneratedVideoModel(Base):
|
||||
__tablename__ = "generated_videos"
|
||||
|
||||
id = Column(String(32), primary_key=True)
|
||||
workspace_id = Column(String(32), nullable=False, index=True)
|
||||
project_id = Column(String(32), nullable=False, index=True)
|
||||
generation_task_id = Column(String(32), nullable=False, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
file_url = Column(String(1000), nullable=False)
|
||||
file_size = Column(Float, nullable=False)
|
||||
duration = Column(Float, nullable=False)
|
||||
thumbnail_url = Column(String(1000), nullable=True)
|
||||
width = Column(Float, nullable=False)
|
||||
height = Column(Float, nullable=False)
|
||||
fps = Column(Float, nullable=False)
|
||||
generated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class TaskModel(Base):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from .asset_libraries import CreateAssetLibraryCommand, CreateAssetLibraryUseCase, ListAssetLibrariesUseCase
|
||||
from .assets import CreateAssetCommand, CreateAssetUseCase, ListAssetsUseCase
|
||||
from .classification_jobs import SubmitClassificationJobCommand, SubmitClassificationJobUseCase
|
||||
from .generated_videos import GetGeneratedVideoUseCase, ListGeneratedVideosByTaskUseCase, ListGeneratedVideosUseCase
|
||||
from .generation_tasks import CreateGenerationTaskCommand, CreateGenerationTaskUseCase, GetGenerationTaskUseCase
|
||||
from .ingest_jobs import SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||
from .projects import CreateProjectCommand, CreateProjectUseCase, ListProjectsUseCase
|
||||
|
||||
@@ -11,10 +13,16 @@ __all__ = [
|
||||
"CreateAssetLibraryCommand",
|
||||
"CreateAssetLibraryUseCase",
|
||||
"CreateAssetUseCase",
|
||||
"CreateGenerationTaskCommand",
|
||||
"CreateGenerationTaskUseCase",
|
||||
"CreateProjectCommand",
|
||||
"CreateProjectUseCase",
|
||||
"GetGeneratedVideoUseCase",
|
||||
"GetGenerationTaskUseCase",
|
||||
"ListAssetLibrariesUseCase",
|
||||
"ListAssetsUseCase",
|
||||
"ListGeneratedVideosByTaskUseCase",
|
||||
"ListGeneratedVideosUseCase",
|
||||
"ListProjectsUseCase",
|
||||
"SubmitClassificationJobCommand",
|
||||
"SubmitClassificationJobUseCase",
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from packages.domain import Asset
|
||||
from packages.domain import Asset, AssetStatus, ClassificationStatus
|
||||
from packages.ports.asset_repository import AssetRepository
|
||||
|
||||
|
||||
@@ -15,6 +15,17 @@ class CreateAssetCommand:
|
||||
storage_key: str
|
||||
mime_type: str
|
||||
metadata: dict[str, object] | None = None
|
||||
file_size: int = 0
|
||||
thumbnail_url: str | None = None
|
||||
duration: float | None = None
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
fps: float | None = None
|
||||
codec: str | None = None
|
||||
status: AssetStatus = AssetStatus.UPLOADING
|
||||
classification_status: ClassificationStatus = ClassificationStatus.PENDING
|
||||
quality_score: float | None = None
|
||||
uploaded_by_user_id: str = ""
|
||||
|
||||
|
||||
class ListAssetsUseCase:
|
||||
@@ -40,5 +51,16 @@ class CreateAssetUseCase:
|
||||
storage_key=command.storage_key,
|
||||
mime_type=command.mime_type,
|
||||
metadata=command.metadata,
|
||||
file_size=command.file_size,
|
||||
thumbnail_url=command.thumbnail_url,
|
||||
duration=command.duration,
|
||||
width=command.width,
|
||||
height=command.height,
|
||||
fps=command.fps,
|
||||
codec=command.codec,
|
||||
status=command.status,
|
||||
classification_status=command.classification_status,
|
||||
quality_score=command.quality_score,
|
||||
uploaded_by_user_id=command.uploaded_by_user_id,
|
||||
)
|
||||
return self.asset_repository.create(asset)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from packages.domain import GeneratedVideo
|
||||
from packages.ports.generated_video_repository import GeneratedVideoRepository
|
||||
|
||||
|
||||
class ListGeneratedVideosUseCase:
|
||||
def __init__(self, generated_video_repository: GeneratedVideoRepository):
|
||||
self.generated_video_repository = generated_video_repository
|
||||
|
||||
def execute(self, project_id: str) -> list[GeneratedVideo]:
|
||||
if not project_id.strip():
|
||||
raise ValueError("project_id 不能为空")
|
||||
return self.generated_video_repository.list_by_project(project_id.strip())
|
||||
|
||||
|
||||
class GetGeneratedVideoUseCase:
|
||||
def __init__(self, generated_video_repository: GeneratedVideoRepository):
|
||||
self.generated_video_repository = generated_video_repository
|
||||
|
||||
def execute(self, video_id: str) -> GeneratedVideo | None:
|
||||
return self.generated_video_repository.get(video_id)
|
||||
|
||||
|
||||
class ListGeneratedVideosByTaskUseCase:
|
||||
def __init__(self, generated_video_repository: GeneratedVideoRepository):
|
||||
self.generated_video_repository = generated_video_repository
|
||||
|
||||
def execute(self, generation_task_id: str) -> list[GeneratedVideo]:
|
||||
if not generation_task_id.strip():
|
||||
raise ValueError("generation_task_id 不能为空")
|
||||
return self.generated_video_repository.list_by_generation_task(generation_task_id.strip())
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from packages.domain import GenerationTask
|
||||
from packages.ports.generation_task_repository import GenerationTaskRepository
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CreateGenerationTaskCommand:
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
asset_library_id: str
|
||||
strategy_id: str = ""
|
||||
voice_library_id: str = ""
|
||||
created_by_user_id: str = ""
|
||||
|
||||
|
||||
class CreateGenerationTaskUseCase:
|
||||
def __init__(self, generation_task_repository: GenerationTaskRepository):
|
||||
self.generation_task_repository = generation_task_repository
|
||||
|
||||
def execute(self, command: CreateGenerationTaskCommand) -> GenerationTask:
|
||||
task = GenerationTask.create(
|
||||
workspace_id=command.workspace_id,
|
||||
project_id=command.project_id,
|
||||
asset_library_id=command.asset_library_id,
|
||||
strategy_id=command.strategy_id,
|
||||
voice_library_id=command.voice_library_id,
|
||||
created_by_user_id=command.created_by_user_id,
|
||||
)
|
||||
return self.generation_task_repository.create(task)
|
||||
|
||||
|
||||
class GetGenerationTaskUseCase:
|
||||
def __init__(self, generation_task_repository: GenerationTaskRepository):
|
||||
self.generation_task_repository = generation_task_repository
|
||||
|
||||
def execute(self, task_id: str) -> GenerationTask | None:
|
||||
return self.generation_task_repository.get(task_id)
|
||||
@@ -1,7 +1,20 @@
|
||||
"""Domain package for core business entities and rules."""
|
||||
|
||||
from .classification import AssetClassification, ClassificationJob, ClassificationJobStatus
|
||||
from .entities import Asset, AssetLibrary, AssetLibraryKind, IngestJob, IngestJobStatus, Project, User, Workspace
|
||||
from .entities import (
|
||||
Asset,
|
||||
AssetLibrary,
|
||||
AssetLibraryKind,
|
||||
AssetStatus,
|
||||
ClassificationStatus,
|
||||
IngestJob,
|
||||
IngestJobStatus,
|
||||
Project,
|
||||
User,
|
||||
Workspace,
|
||||
)
|
||||
from .generation_task import GenerationTask, GenerationTaskStatus
|
||||
from .generated_video import GeneratedVideo
|
||||
from .project_management import Milestone, Task, TaskIssue, TaskPriority, TaskStatus
|
||||
|
||||
__all__ = [
|
||||
@@ -9,8 +22,13 @@ __all__ = [
|
||||
"AssetClassification",
|
||||
"AssetLibrary",
|
||||
"AssetLibraryKind",
|
||||
"AssetStatus",
|
||||
"ClassificationJob",
|
||||
"ClassificationJobStatus",
|
||||
"ClassificationStatus",
|
||||
"GeneratedVideo",
|
||||
"GenerationTask",
|
||||
"GenerationTaskStatus",
|
||||
"IngestJob",
|
||||
"IngestJobStatus",
|
||||
"Milestone",
|
||||
|
||||
+10
-98
@@ -1,105 +1,17 @@
|
||||
"""
|
||||
Asset 实体 - 素材
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
"""兼容层:旧素材领域入口,转发到当前主线实体。"""
|
||||
|
||||
from packages.domain.entities import Asset, AssetStatus, ClassificationStatus
|
||||
|
||||
|
||||
class AssetType(str, Enum):
|
||||
"""素材类型"""
|
||||
class AssetType:
|
||||
VIDEO = "video"
|
||||
IMAGE = "image"
|
||||
AUDIO = "audio"
|
||||
|
||||
|
||||
class AssetStatus(str, Enum):
|
||||
"""素材状态"""
|
||||
UPLOADING = "uploading"
|
||||
READY = "ready"
|
||||
PROCESSING = "processing"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class ClassificationStatus(str, Enum):
|
||||
"""分类状态"""
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class Asset:
|
||||
"""素材实体"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
id: str,
|
||||
workspace_id: str,
|
||||
project_id: str,
|
||||
asset_library_id: str,
|
||||
name: str,
|
||||
file_type: AssetType,
|
||||
file_size: int,
|
||||
file_url: str,
|
||||
uploaded_by_user_id: str,
|
||||
status: AssetStatus = AssetStatus.UPLOADING,
|
||||
thumbnail_url: Optional[str] = None,
|
||||
duration: Optional[float] = None,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
fps: Optional[float] = None,
|
||||
codec: Optional[str] = None,
|
||||
classification_status: ClassificationStatus = ClassificationStatus.PENDING,
|
||||
classification_result: Optional[dict] = None,
|
||||
quality_score: Optional[float] = None,
|
||||
created_at: Optional[datetime] = None,
|
||||
updated_at: Optional[datetime] = None,
|
||||
):
|
||||
self.id = id
|
||||
self.workspace_id = workspace_id
|
||||
self.project_id = project_id
|
||||
self.asset_library_id = asset_library_id
|
||||
self.name = name
|
||||
self.file_type = file_type
|
||||
self.file_size = file_size
|
||||
self.file_url = file_url
|
||||
self.uploaded_by_user_id = uploaded_by_user_id
|
||||
self.status = status
|
||||
self.thumbnail_url = thumbnail_url
|
||||
self.duration = duration
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.fps = fps
|
||||
self.codec = codec
|
||||
self.classification_status = classification_status
|
||||
self.classification_result = classification_result
|
||||
self.quality_score = quality_score
|
||||
self.created_at = created_at or datetime.utcnow()
|
||||
self.updated_at = updated_at or datetime.utcnow()
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"workspace_id": self.workspace_id,
|
||||
"project_id": self.project_id,
|
||||
"asset_library_id": self.asset_library_id,
|
||||
"name": self.name,
|
||||
"file_type": self.file_type.value,
|
||||
"file_size": self.file_size,
|
||||
"file_url": self.file_url,
|
||||
"thumbnail_url": self.thumbnail_url,
|
||||
"duration": self.duration,
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
"fps": self.fps,
|
||||
"codec": self.codec,
|
||||
"status": self.status.value,
|
||||
"classification_status": self.classification_status.value,
|
||||
"classification_result": self.classification_result,
|
||||
"quality_score": self.quality_score,
|
||||
"uploaded_by_user_id": self.uploaded_by_user_id,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
__all__ = [
|
||||
"Asset",
|
||||
"AssetStatus",
|
||||
"AssetType",
|
||||
"ClassificationStatus",
|
||||
]
|
||||
|
||||
@@ -1,52 +1,16 @@
|
||||
"""
|
||||
AssetLibrary 实体 - 素材库
|
||||
"""
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
"""兼容层:旧素材库领域入口,转发到当前主线实体。"""
|
||||
|
||||
from packages.domain.entities import AssetLibrary, AssetLibraryKind
|
||||
|
||||
|
||||
class LibraryKind(str, Enum):
|
||||
"""素材库类型"""
|
||||
VIDEO = "video"
|
||||
VOICE = "voice"
|
||||
IMAGE = "image"
|
||||
class LibraryKind:
|
||||
VIDEO = AssetLibraryKind.VIDEO
|
||||
VOICE = AssetLibraryKind.VOICE
|
||||
IMAGE = AssetLibraryKind.IMAGE
|
||||
|
||||
|
||||
class AssetLibrary:
|
||||
"""素材库实体"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
id: str,
|
||||
workspace_id: str,
|
||||
name: str,
|
||||
kind: LibraryKind,
|
||||
project_id: str = None,
|
||||
asset_count: int = 0,
|
||||
total_size: int = 0,
|
||||
created_at: datetime = None,
|
||||
updated_at: datetime = None,
|
||||
):
|
||||
self.id = id
|
||||
self.workspace_id = workspace_id
|
||||
self.project_id = project_id
|
||||
self.name = name
|
||||
self.kind = kind
|
||||
self.asset_count = asset_count
|
||||
self.total_size = total_size
|
||||
self.created_at = created_at or datetime.utcnow()
|
||||
self.updated_at = updated_at or datetime.utcnow()
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"workspace_id": self.workspace_id,
|
||||
"project_id": self.project_id,
|
||||
"name": self.name,
|
||||
"kind": self.kind.value,
|
||||
"asset_count": self.asset_count,
|
||||
"total_size": self.total_size,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
__all__ = [
|
||||
"AssetLibrary",
|
||||
"AssetLibraryKind",
|
||||
"LibraryKind",
|
||||
]
|
||||
|
||||
@@ -10,6 +10,7 @@ from uuid import uuid4
|
||||
class AssetLibraryKind(StrEnum):
|
||||
VIDEO = "video"
|
||||
VOICE = "voice"
|
||||
IMAGE = "image"
|
||||
|
||||
|
||||
class IngestJobStatus(StrEnum):
|
||||
@@ -122,7 +123,10 @@ class AssetLibrary:
|
||||
project_id: str
|
||||
name: str
|
||||
kind: AssetLibraryKind
|
||||
asset_count: int = 0
|
||||
total_size: int = 0
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
@@ -141,9 +145,25 @@ class AssetLibrary:
|
||||
project_id=project_id,
|
||||
name=clean_name,
|
||||
kind=kind,
|
||||
asset_count=0,
|
||||
total_size=0,
|
||||
)
|
||||
|
||||
|
||||
class AssetStatus(StrEnum):
|
||||
UPLOADING = "uploading"
|
||||
READY = "ready"
|
||||
PROCESSING = "processing"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class ClassificationStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Asset:
|
||||
id: str
|
||||
@@ -153,9 +173,21 @@ class Asset:
|
||||
name: str
|
||||
storage_key: str
|
||||
mime_type: str
|
||||
file_size: int = 0
|
||||
thumbnail_url: str | None = None
|
||||
duration: float | None = None
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
fps: float | None = None
|
||||
codec: str | None = None
|
||||
status: AssetStatus = AssetStatus.UPLOADING
|
||||
classification_status: ClassificationStatus = ClassificationStatus.PENDING
|
||||
quality_score: float | None = None
|
||||
uploaded_by_user_id: str = ""
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
tags: list[str] = field(default_factory=list)
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
@@ -167,6 +199,18 @@ class Asset:
|
||||
storage_key: str,
|
||||
mime_type: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
file_size: int = 0,
|
||||
thumbnail_url: str | None = None,
|
||||
duration: float | None = None,
|
||||
width: int | None = None,
|
||||
height: int | None = None,
|
||||
fps: float | None = None,
|
||||
codec: str | None = None,
|
||||
status: AssetStatus = AssetStatus.UPLOADING,
|
||||
classification_status: ClassificationStatus = ClassificationStatus.PENDING,
|
||||
quality_score: float | None = None,
|
||||
uploaded_by_user_id: str = "",
|
||||
) -> "Asset":
|
||||
clean_name = name.strip()
|
||||
if not clean_name:
|
||||
@@ -183,6 +227,17 @@ class Asset:
|
||||
name=clean_name,
|
||||
storage_key=storage_key.strip(),
|
||||
mime_type=mime_type.strip(),
|
||||
file_size=file_size,
|
||||
thumbnail_url=thumbnail_url,
|
||||
duration=duration,
|
||||
width=width,
|
||||
height=height,
|
||||
fps=fps,
|
||||
codec=codec,
|
||||
status=status,
|
||||
classification_status=classification_status,
|
||||
quality_score=quality_score,
|
||||
uploaded_by_user_id=uploaded_by_user_id.strip(),
|
||||
metadata=metadata or {},
|
||||
tags=[],
|
||||
)
|
||||
@@ -194,12 +249,14 @@ class Asset:
|
||||
raise ValueError("标签不能为空")
|
||||
if clean_tag not in self.tags:
|
||||
self.tags.append(clean_tag)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def remove_tag(self, tag: str) -> None:
|
||||
"""删除标签。如果标签不存在,不报错(幂等性)。"""
|
||||
clean_tag = tag.strip()
|
||||
if clean_tag in self.tags:
|
||||
self.tags.remove(clean_tag)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GeneratedVideo:
|
||||
id: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
generation_task_id: str
|
||||
name: str
|
||||
file_url: str
|
||||
file_size: int
|
||||
duration: float
|
||||
width: int
|
||||
height: int
|
||||
fps: float
|
||||
thumbnail_url: str | None = None
|
||||
generated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
workspace_id: str,
|
||||
project_id: str,
|
||||
generation_task_id: str,
|
||||
name: str,
|
||||
file_url: str,
|
||||
*,
|
||||
file_size: int,
|
||||
duration: float,
|
||||
width: int,
|
||||
height: int,
|
||||
fps: float,
|
||||
thumbnail_url: str | None = None,
|
||||
) -> "GeneratedVideo":
|
||||
if not workspace_id.strip():
|
||||
raise ValueError("workspace_id 不能为空")
|
||||
if not project_id.strip():
|
||||
raise ValueError("project_id 不能为空")
|
||||
if not generation_task_id.strip():
|
||||
raise ValueError("generation_task_id 不能为空")
|
||||
if not name.strip():
|
||||
raise ValueError("name 不能为空")
|
||||
if not file_url.strip():
|
||||
raise ValueError("file_url 不能为空")
|
||||
return cls(
|
||||
id=uuid4().hex,
|
||||
workspace_id=workspace_id.strip(),
|
||||
project_id=project_id.strip(),
|
||||
generation_task_id=generation_task_id.strip(),
|
||||
name=name.strip(),
|
||||
file_url=file_url.strip(),
|
||||
file_size=file_size,
|
||||
duration=duration,
|
||||
width=width,
|
||||
height=height,
|
||||
fps=fps,
|
||||
thumbnail_url=thumbnail_url,
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import StrEnum
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
class GenerationTaskStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GenerationTask:
|
||||
id: str
|
||||
workspace_id: str
|
||||
project_id: str
|
||||
asset_library_id: str
|
||||
strategy_id: str = ""
|
||||
voice_library_id: str = ""
|
||||
status: GenerationTaskStatus = GenerationTaskStatus.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))
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
workspace_id: str,
|
||||
project_id: str,
|
||||
asset_library_id: str,
|
||||
*,
|
||||
strategy_id: str = "",
|
||||
voice_library_id: str = "",
|
||||
created_by_user_id: str = "",
|
||||
) -> "GenerationTask":
|
||||
if not workspace_id.strip():
|
||||
raise ValueError("workspace_id 不能为空")
|
||||
if not project_id.strip():
|
||||
raise ValueError("project_id 不能为空")
|
||||
if not asset_library_id.strip():
|
||||
raise ValueError("asset_library_id 不能为空")
|
||||
return cls(
|
||||
id=uuid4().hex,
|
||||
workspace_id=workspace_id.strip(),
|
||||
project_id=project_id.strip(),
|
||||
asset_library_id=asset_library_id.strip(),
|
||||
strategy_id=strategy_id.strip(),
|
||||
voice_library_id=voice_library_id.strip(),
|
||||
created_by_user_id=created_by_user_id.strip(),
|
||||
)
|
||||
@@ -1,31 +1,25 @@
|
||||
"""
|
||||
AssetLibrary Repository 接口
|
||||
"""
|
||||
"""兼容层:旧素材库仓储接口定义,保留给遗留异步适配器使用。"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Optional
|
||||
from packages.domain.asset_library import AssetLibrary, LibraryKind
|
||||
|
||||
from packages.domain import AssetLibrary, AssetLibraryKind
|
||||
|
||||
|
||||
class AssetLibraryRepository(ABC):
|
||||
"""素材库仓储接口"""
|
||||
|
||||
@abstractmethod
|
||||
async def create(self, library: AssetLibrary) -> AssetLibrary:
|
||||
"""创建素材库"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def find_by_id(self, library_id: str) -> Optional[AssetLibrary]:
|
||||
"""根据 ID 查询素材库"""
|
||||
async def find_by_id(self, library_id: str) -> AssetLibrary | None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def find_by_workspace(
|
||||
self,
|
||||
workspace_id: str,
|
||||
kind: Optional[LibraryKind] = None,
|
||||
) -> List[AssetLibrary]:
|
||||
"""根据工作空间查询素材库"""
|
||||
kind: AssetLibraryKind | None = None,
|
||||
) -> list[AssetLibrary]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -33,26 +27,21 @@ class AssetLibraryRepository(ABC):
|
||||
self,
|
||||
project_id: str,
|
||||
workspace_id: str,
|
||||
) -> List[AssetLibrary]:
|
||||
"""根据项目查询素材库"""
|
||||
) -> list[AssetLibrary]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def update(self, library: AssetLibrary) -> AssetLibrary:
|
||||
"""更新素材库"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, library_id: str, workspace_id: str) -> bool:
|
||||
"""删除素材库"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def increment_asset_count(self, library_id: str, size_delta: int) -> None:
|
||||
"""增加素材数量和大小"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def decrement_asset_count(self, library_id: str, size_delta: int) -> None:
|
||||
"""减少素材数量和大小"""
|
||||
pass
|
||||
|
||||
@@ -1,33 +1,27 @@
|
||||
"""
|
||||
Asset Repository 接口
|
||||
"""
|
||||
"""兼容层:旧素材仓储接口定义,保留给遗留异步适配器使用。"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Optional
|
||||
from packages.domain.asset import Asset
|
||||
|
||||
from packages.domain import Asset
|
||||
|
||||
|
||||
class AssetRepository(ABC):
|
||||
"""素材仓储接口"""
|
||||
|
||||
@abstractmethod
|
||||
async def create(self, asset: Asset) -> Asset:
|
||||
"""创建素材"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def find_by_id(self, asset_id: str) -> Optional[Asset]:
|
||||
"""根据 ID 查询素材"""
|
||||
async def find_by_id(self, asset_id: str) -> Asset | None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def find_by_project(
|
||||
self,
|
||||
self,
|
||||
project_id: str,
|
||||
workspace_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> List[Asset]:
|
||||
"""根据项目查询素材列表"""
|
||||
) -> list[Asset]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -37,21 +31,17 @@ class AssetRepository(ABC):
|
||||
workspace_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> List[Asset]:
|
||||
"""根据素材库查询素材列表"""
|
||||
) -> list[Asset]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def update(self, asset: Asset) -> Asset:
|
||||
"""更新素材"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, asset_id: str, workspace_id: str) -> bool:
|
||||
"""删除素材"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def count_by_project(self, project_id: str, workspace_id: str) -> int:
|
||||
"""统计项目素材数量"""
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from packages.domain import GeneratedVideo
|
||||
|
||||
|
||||
class GeneratedVideoRepository(Protocol):
|
||||
def create(self, video: GeneratedVideo) -> GeneratedVideo:
|
||||
...
|
||||
|
||||
def get(self, video_id: str) -> GeneratedVideo | None:
|
||||
...
|
||||
|
||||
def list_by_project(self, project_id: str) -> list[GeneratedVideo]:
|
||||
...
|
||||
|
||||
def list_by_generation_task(self, generation_task_id: str) -> list[GeneratedVideo]:
|
||||
...
|
||||
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from packages.domain import GenerationTask
|
||||
|
||||
|
||||
class GenerationTaskRepository(Protocol):
|
||||
def create(self, task: GenerationTask) -> GenerationTask:
|
||||
...
|
||||
|
||||
def get(self, task_id: str) -> GenerationTask | None:
|
||||
...
|
||||
|
||||
def list_by_project(self, project_id: str) -> list[GenerationTask]:
|
||||
...
|
||||
|
||||
def update(self, task: GenerationTask) -> GenerationTask:
|
||||
...
|
||||
@@ -0,0 +1,41 @@
|
||||
from packages.application import CreateGenerationTaskCommand, CreateGenerationTaskUseCase
|
||||
from packages.domain import GenerationTaskStatus
|
||||
|
||||
|
||||
class DummyGenerationTaskRepository:
|
||||
def __init__(self):
|
||||
self.items = {}
|
||||
|
||||
def create(self, task):
|
||||
self.items[task.id] = task
|
||||
return task
|
||||
|
||||
def get(self, task_id):
|
||||
return self.items.get(task_id)
|
||||
|
||||
def list_by_project(self, project_id):
|
||||
return [task for task in self.items.values() if task.project_id == project_id]
|
||||
|
||||
def update(self, task):
|
||||
self.items[task.id] = task
|
||||
return task
|
||||
|
||||
|
||||
def test_create_generation_task_smoke():
|
||||
repo = DummyGenerationTaskRepository()
|
||||
use_case = CreateGenerationTaskUseCase(repo)
|
||||
task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
workspace_id="ws-1",
|
||||
project_id="proj-1",
|
||||
asset_library_id="lib-1",
|
||||
strategy_id="str-1",
|
||||
voice_library_id="voice-1",
|
||||
created_by_user_id="user-1",
|
||||
)
|
||||
)
|
||||
assert task.workspace_id == "ws-1"
|
||||
assert task.project_id == "proj-1"
|
||||
assert task.asset_library_id == "lib-1"
|
||||
assert task.status == GenerationTaskStatus.PENDING
|
||||
assert repo.get(task.id) is not None
|
||||
Reference in New Issue
Block a user