merge: CI/CD stability fixes into develop
This commit is contained in:
+14
-38
@@ -6,14 +6,17 @@ on:
|
||||
- main
|
||||
- develop
|
||||
- 'feature/**'
|
||||
- 'bugfix/**'
|
||||
- 'hotfix/**'
|
||||
- 'release/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
code-quality:
|
||||
name: Code Quality Check
|
||||
validate:
|
||||
name: Validate Code Quality And Tests
|
||||
runs-on: ubuntu-latest
|
||||
container: catthehacker/ubuntu:act-latest
|
||||
|
||||
@@ -23,60 +26,33 @@ jobs:
|
||||
git clone --depth=1 --branch=${GITHUB_REF_NAME} https://api.xiaoxiajianji.com/git/${GITHUB_REPOSITORY}.git .
|
||||
git checkout ${GITHUB_SHA}
|
||||
|
||||
- name: Prepare quality environment
|
||||
- name: Prepare environment
|
||||
run: |
|
||||
python3 --version
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
python -m pip install --index-url https://pypi.org/simple --upgrade pip
|
||||
python -m pip install --index-url https://pypi.org/simple -r requirements-quality.txt
|
||||
python -m pip install --index-url https://pypi.org/simple -r requirements-dev.txt
|
||||
|
||||
- name: Run checks
|
||||
- name: Run code quality checks
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
black --version
|
||||
isort --version-number
|
||||
flake8 --version
|
||||
mypy --version
|
||||
python -m black --version
|
||||
python -m isort --version-number
|
||||
python -m flake8 --version
|
||||
python -m mypy --version
|
||||
bandit --version
|
||||
echo "✅ Code quality environment is ready"
|
||||
|
||||
test:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
container: catthehacker/ubuntu:act-latest
|
||||
needs:
|
||||
- code-quality
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
run: |
|
||||
git clone --depth=1 --branch=${GITHUB_REF_NAME} https://api.xiaoxiajianji.com/git/${GITHUB_REPOSITORY}.git .
|
||||
git checkout ${GITHUB_SHA}
|
||||
|
||||
- name: Prepare test environment
|
||||
run: |
|
||||
python3 --version
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
python -m pip install --index-url https://pypi.org/simple --upgrade pip
|
||||
python -m pip install --index-url https://pypi.org/simple -r requirements-dev.txt
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
pytest --version
|
||||
echo "✅ Test environment is ready"
|
||||
|
||||
build-summary:
|
||||
name: Build Summary
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- test
|
||||
if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- name: Summary
|
||||
- name: Build summary
|
||||
if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
echo "✅ Build completed successfully!"
|
||||
echo "Branch: ${GITHUB_REF_NAME}"
|
||||
|
||||
+14
-38
@@ -6,14 +6,17 @@ on:
|
||||
- main
|
||||
- develop
|
||||
- 'feature/**'
|
||||
- 'bugfix/**'
|
||||
- 'hotfix/**'
|
||||
- 'release/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
code-quality:
|
||||
name: Code Quality Check
|
||||
validate:
|
||||
name: Validate Code Quality And Tests
|
||||
runs-on: ubuntu-latest
|
||||
container: catthehacker/ubuntu:act-latest
|
||||
|
||||
@@ -23,60 +26,33 @@ jobs:
|
||||
git clone --depth=1 --branch=${GITHUB_REF_NAME} https://api.xiaoxiajianji.com/git/${GITHUB_REPOSITORY}.git .
|
||||
git checkout ${GITHUB_SHA}
|
||||
|
||||
- name: Prepare quality environment
|
||||
- name: Prepare environment
|
||||
run: |
|
||||
python3 --version
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
python -m pip install --index-url https://pypi.org/simple --upgrade pip
|
||||
python -m pip install --index-url https://pypi.org/simple -r requirements-quality.txt
|
||||
python -m pip install --index-url https://pypi.org/simple -r requirements-dev.txt
|
||||
|
||||
- name: Run checks
|
||||
- name: Run code quality checks
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
black --version
|
||||
isort --version-number
|
||||
flake8 --version
|
||||
mypy --version
|
||||
python -m black --version
|
||||
python -m isort --version-number
|
||||
python -m flake8 --version
|
||||
python -m mypy --version
|
||||
bandit --version
|
||||
echo "✅ Code quality environment is ready"
|
||||
|
||||
test:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
container: catthehacker/ubuntu:act-latest
|
||||
needs:
|
||||
- code-quality
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
run: |
|
||||
git clone --depth=1 --branch=${GITHUB_REF_NAME} https://api.xiaoxiajianji.com/git/${GITHUB_REPOSITORY}.git .
|
||||
git checkout ${GITHUB_SHA}
|
||||
|
||||
- name: Prepare test environment
|
||||
run: |
|
||||
python3 --version
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
python -m pip install --index-url https://pypi.org/simple --upgrade pip
|
||||
python -m pip install --index-url https://pypi.org/simple -r requirements-dev.txt
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
pytest --version
|
||||
echo "✅ Test environment is ready"
|
||||
|
||||
build-summary:
|
||||
name: Build Summary
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- test
|
||||
if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- name: Summary
|
||||
- name: Build summary
|
||||
if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
echo "✅ Build completed successfully!"
|
||||
echo "Branch: ${GITHUB_REF_NAME}"
|
||||
|
||||
@@ -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,84 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.core.storage import MinIOService, get_minio_service
|
||||
from app.dependencies import get_generated_video_repository
|
||||
from app.schemas.generated_video import (
|
||||
GeneratedVideoDownloadUrlResponse,
|
||||
GeneratedVideoResponse,
|
||||
ListGeneratedVideosResponse,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl import SQLAlchemyGeneratedVideoRepository
|
||||
from packages.application import (
|
||||
GetGeneratedVideoDownloadUrlUseCase,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{video_id}/download-url", response_model=GeneratedVideoDownloadUrlResponse)
|
||||
def get_generated_video_download_url(
|
||||
video_id: str,
|
||||
generated_video_repository: SQLAlchemyGeneratedVideoRepository = Depends(get_generated_video_repository),
|
||||
storage_service: MinIOService = Depends(get_minio_service),
|
||||
) -> GeneratedVideoDownloadUrlResponse:
|
||||
use_case = GetGeneratedVideoDownloadUrlUseCase(generated_video_repository)
|
||||
file_url = use_case.execute(video_id)
|
||||
if file_url is None:
|
||||
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
|
||||
download_url = storage_service.get_download_url(file_url)
|
||||
return GeneratedVideoDownloadUrlResponse(video_id=video_id, download_url=download_url)
|
||||
@@ -0,0 +1,97 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.core.celery_app import celery_app
|
||||
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,
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.generate_video", args=[task.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
|
||||
]
|
||||
)
|
||||
@@ -1,5 +1,7 @@
|
||||
"""MinIO storage service for file uploads."""
|
||||
from datetime import timedelta
|
||||
from typing import BinaryIO
|
||||
from urllib.parse import urlparse
|
||||
import os
|
||||
|
||||
from minio import Minio
|
||||
@@ -67,6 +69,25 @@ class MinIOService:
|
||||
def get_url(self, storage_key: str) -> str:
|
||||
return f"{self.public_url}/{self.bucket_name}/{storage_key}"
|
||||
|
||||
def get_download_url(self, storage_key_or_url: str, expires_seconds: int = 3600) -> str:
|
||||
storage_key = self._normalize_storage_key(storage_key_or_url)
|
||||
try:
|
||||
return self.client.presigned_get_object(
|
||||
self.bucket_name,
|
||||
storage_key,
|
||||
expires=timedelta(seconds=expires_seconds),
|
||||
)
|
||||
except S3Error:
|
||||
return self.get_url(storage_key)
|
||||
|
||||
def _normalize_storage_key(self, storage_key_or_url: str) -> str:
|
||||
prefix = f"/{self.bucket_name}/"
|
||||
if storage_key_or_url.startswith("http://") or storage_key_or_url.startswith("https://"):
|
||||
parsed = urlparse(storage_key_or_url)
|
||||
if prefix in parsed.path:
|
||||
return parsed.path.split(prefix, 1)[1]
|
||||
return storage_key_or_url.lstrip("/")
|
||||
|
||||
def delete_file(self, storage_key: str):
|
||||
try:
|
||||
self.client.remove_object(self.bucket_name, storage_key)
|
||||
|
||||
@@ -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,25 @@
|
||||
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 GeneratedVideoDownloadUrlResponse(BaseModel):
|
||||
video_id: str
|
||||
download_url: str
|
||||
|
||||
|
||||
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
|
||||
@@ -19,7 +19,7 @@ export interface AssetLibraryItem {
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
kind: 'video' | 'voice';
|
||||
kind: 'video' | 'voice' | 'image';
|
||||
}
|
||||
|
||||
export interface IngestJob {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface GenerationTaskItem {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
asset_library_id: string;
|
||||
strategy_id: string;
|
||||
voice_library_id: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
progress: number;
|
||||
result_count: number;
|
||||
error_message: string;
|
||||
}
|
||||
|
||||
export interface GeneratedVideoItem {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
generation_task_id: string;
|
||||
name: string;
|
||||
file_url: string;
|
||||
file_size: number;
|
||||
duration: number;
|
||||
thumbnail_url?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
}
|
||||
|
||||
export const createGenerationTask = async (data: {
|
||||
workspace_id: string;
|
||||
project_id: string;
|
||||
asset_library_id: string;
|
||||
strategy_id?: string;
|
||||
voice_library_id?: string;
|
||||
created_by_user_id?: string;
|
||||
}): Promise<GenerationTaskItem> => {
|
||||
const response = await apiClient.post('/generation/tasks', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getGenerationTask = async (taskId: string): Promise<GenerationTaskItem> => {
|
||||
const response = await apiClient.get(`/generation/tasks/${taskId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getGenerationResults = async (taskId: string): Promise<GeneratedVideoItem[]> => {
|
||||
const response = await apiClient.get(`/generation/tasks/${taskId}/results`);
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
export const getGeneratedVideos = async (projectId: string): Promise<GeneratedVideoItem[]> => {
|
||||
const response = await apiClient.get('/generated-videos', { params: { project_id: projectId } });
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
export const getGeneratedVideoDownloadUrl = async (videoId: string): Promise<string> => {
|
||||
const response = await apiClient.get(`/generated-videos/${videoId}/download-url`);
|
||||
return response.data.download_url;
|
||||
};
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Table, Button, Tag, Space, Popconfirm, message, Select } from 'antd';
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import { DeleteOutlined } from '@ant-design/icons';
|
||||
import { useWorkspaceMembers } from '@/hooks/useWorkspace';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { removeMember, updateMemberRole } from '@/api/workspace';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 认证相关 Hooks
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import * as authApi from '@/api/auth';
|
||||
@@ -68,12 +69,17 @@ export const useCurrentUser = () => {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const setUser = useAuthStore((state) => state.setUser);
|
||||
|
||||
return useQuery({
|
||||
const query = useQuery<authApi.User>({
|
||||
queryKey: ['currentUser'],
|
||||
queryFn: authApi.getCurrentUser,
|
||||
enabled: isAuthenticated,
|
||||
onSuccess: (user) => {
|
||||
setUser(user);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data) {
|
||||
setUser(query.data);
|
||||
}
|
||||
}, [query.data, setUser]);
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 工作空间相关 Hooks
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import * as workspaceApi from '@/api/workspace';
|
||||
import { useWorkspaceStore } from '@/store/workspaceStore';
|
||||
@@ -9,18 +10,23 @@ import { useWorkspaceStore } from '@/store/workspaceStore';
|
||||
export const useWorkspaces = () => {
|
||||
const setWorkspaces = useWorkspaceStore((state) => state.setWorkspaces);
|
||||
|
||||
return useQuery({
|
||||
const query = useQuery<workspaceApi.Workspace[]>({
|
||||
queryKey: ['workspaces'],
|
||||
queryFn: workspaceApi.getWorkspaces,
|
||||
onSuccess: (data) => {
|
||||
setWorkspaces(data);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data) {
|
||||
setWorkspaces(query.data);
|
||||
}
|
||||
}, [query.data, setWorkspaces]);
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
// 获取工作空间详情
|
||||
export const useWorkspace = (id: string) => {
|
||||
return useQuery({
|
||||
return useQuery<workspaceApi.Workspace>({
|
||||
queryKey: ['workspace', id],
|
||||
queryFn: () => workspaceApi.getWorkspace(id),
|
||||
enabled: !!id,
|
||||
@@ -43,7 +49,7 @@ export const useCreateWorkspace = () => {
|
||||
|
||||
// 获取成员列表
|
||||
export const useWorkspaceMembers = (workspaceId: string) => {
|
||||
return useQuery({
|
||||
return useQuery<workspaceApi.WorkspaceMember[]>({
|
||||
queryKey: ['workspaceMembers', workspaceId],
|
||||
queryFn: () => workspaceApi.getMembers(workspaceId),
|
||||
enabled: !!workspaceId,
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import {
|
||||
TrendingUpOutlined,
|
||||
ArrowUpOutlined,
|
||||
UserAddOutlined,
|
||||
DollarOutlined,
|
||||
ProjectOutlined,
|
||||
@@ -58,7 +58,7 @@ const Analytics: React.FC = () => {
|
||||
// 活跃度统计
|
||||
const activityStats = [
|
||||
{ metric: '日活跃用户 (DAU)', value: 892, growth: '+12.5%', icon: <UserAddOutlined /> },
|
||||
{ metric: '月活跃用户 (MAU)', value: 3456, growth: '+8.3%', icon: <TrendingUpOutlined /> },
|
||||
{ metric: '月活跃用户 (MAU)', value: 3456, growth: '+8.3%', icon: <ArrowUpOutlined /> },
|
||||
{ metric: '本月收入', value: 13365, prefix: '¥', growth: '+51.6%', icon: <DollarOutlined /> },
|
||||
{ metric: '活跃项目数', value: 2341, growth: '+18.2%', icon: <ProjectOutlined /> },
|
||||
];
|
||||
|
||||
@@ -67,8 +67,8 @@ const SystemMonitor: React.FC = () => {
|
||||
warning: { color: 'warning', icon: <SyncOutlined spin />, text: '警告' },
|
||||
error: { color: 'error', icon: <CloseCircleOutlined />, text: '故障' },
|
||||
};
|
||||
const { color, icon, text } = config[status] || config.healthy;
|
||||
return <Badge status={color as any} icon={icon} text={text} />;
|
||||
const { color, text } = config[status] || config.healthy;
|
||||
return <Badge status={color as any} text={text} />;
|
||||
},
|
||||
},
|
||||
{ title: '可用性', dataIndex: 'uptime', key: 'uptime' },
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 账号安全设置页面
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Card, Form, Input, Button, message, Divider } from 'antd';
|
||||
import { Card, Form, Input, Button, message } from 'antd';
|
||||
import { LockOutlined, MailOutlined } from '@ant-design/icons';
|
||||
|
||||
const AccountSecurity: React.FC = () => {
|
||||
|
||||
@@ -73,7 +73,7 @@ const Billing: React.FC = () => {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: Invoice) => (
|
||||
render: (_: any) => (
|
||||
<Space>
|
||||
<Button type="link" icon={<DownloadOutlined />}>
|
||||
下载
|
||||
|
||||
@@ -95,7 +95,6 @@ const ProjectAssets: React.FC = () => {
|
||||
const [batchMode, setBatchMode] = useState<'unclassified_only' | 'include_classified'>('unclassified_only');
|
||||
const [batchProgress, setBatchProgress] = useState<BatchProgressState>(defaultBatchProgress);
|
||||
const [batchJobIds, setBatchJobIds] = useState<string[]>([]);
|
||||
const [batchCompletedIds, setBatchCompletedIds] = useState<string[]>([]);
|
||||
const [batchFailedIds, setBatchFailedIds] = useState<string[]>([]);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
@@ -180,7 +179,6 @@ const ProjectAssets: React.FC = () => {
|
||||
const failedIds = results.filter((job) => job.status === 'failed').map((job) => job.id);
|
||||
const pendingCount = results.filter((job) => job.status === 'pending' || job.status === 'processing').length;
|
||||
|
||||
setBatchCompletedIds(completedIds);
|
||||
setBatchFailedIds(failedIds);
|
||||
setBatchProgress((current) => ({
|
||||
...current,
|
||||
@@ -293,7 +291,6 @@ const ProjectAssets: React.FC = () => {
|
||||
|
||||
setBatchProgress({ total: selectedAssets.length, submitted: 0, failed: 0, skipped: skippedAssets.length, completed: 0, running: true, tracking: false });
|
||||
setBatchJobIds([]);
|
||||
setBatchCompletedIds([]);
|
||||
setBatchFailedIds([]);
|
||||
|
||||
let submitted = 0;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Alert, Button, Card, Form, Input, Select, Space, Typography, message } from 'antd';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { createGenerationTask, getGenerationResults, getGenerationTask } from '@/api/generation';
|
||||
import { getAssetLibraries } from '@/api/assets';
|
||||
|
||||
const ProjectGeneration: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const projectId = id || '';
|
||||
const [workspaceId, setWorkspaceId] = useState('demo-workspace');
|
||||
const [taskId, setTaskId] = useState('');
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const librariesQuery = useQuery({
|
||||
queryKey: ['generation-libraries', projectId],
|
||||
queryFn: () => getAssetLibraries(projectId),
|
||||
enabled: !!projectId,
|
||||
});
|
||||
|
||||
const taskQuery = useQuery({
|
||||
queryKey: ['generation-task', taskId],
|
||||
queryFn: () => getGenerationTask(taskId),
|
||||
enabled: !!taskId,
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
return status === 'completed' || status === 'failed' || status === 'cancelled' ? false : 2000;
|
||||
},
|
||||
});
|
||||
|
||||
const resultsQuery = useQuery({
|
||||
queryKey: ['generation-results', taskId],
|
||||
queryFn: () => getGenerationResults(taskId),
|
||||
enabled: !!taskId,
|
||||
});
|
||||
|
||||
const generationMutation = useMutation({
|
||||
mutationFn: createGenerationTask,
|
||||
onSuccess: (task) => {
|
||||
setTaskId(task.id);
|
||||
message.success('生成任务已创建');
|
||||
},
|
||||
onError: () => message.error('创建生成任务失败'),
|
||||
});
|
||||
|
||||
const assetLibraryOptions = useMemo(
|
||||
() => (librariesQuery.data || []).filter((item) => item.kind === 'video' || item.kind === 'image').map((item) => ({ label: `${item.name} (${item.kind})`, value: item.id })),
|
||||
[librariesQuery.data]
|
||||
);
|
||||
|
||||
const voiceLibraryOptions = useMemo(
|
||||
() => (librariesQuery.data || []).filter((item) => item.kind === 'voice').map((item) => ({ label: `${item.name} (${item.kind})`, value: item.id })),
|
||||
[librariesQuery.data]
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card title="项目生成任务">
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={(values) =>
|
||||
generationMutation.mutate({
|
||||
workspace_id: workspaceId,
|
||||
project_id: projectId,
|
||||
asset_library_id: values.asset_library_id,
|
||||
voice_library_id: values.voice_library_id || '',
|
||||
strategy_id: values.strategy_id || '',
|
||||
created_by_user_id: values.created_by_user_id || '',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Form.Item label="Workspace ID">
|
||||
<Input value={workspaceId} onChange={(e) => setWorkspaceId(e.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="素材库" name="asset_library_id" rules={[{ required: true, message: '请选择素材库' }]}>
|
||||
<Select options={assetLibraryOptions} placeholder="选择视频/图片素材库" loading={librariesQuery.isLoading} />
|
||||
</Form.Item>
|
||||
<Form.Item label="配音库" name="voice_library_id">
|
||||
<Select allowClear options={voiceLibraryOptions} placeholder="可选:选择配音库" loading={librariesQuery.isLoading} />
|
||||
</Form.Item>
|
||||
<Form.Item label="策略 ID" name="strategy_id">
|
||||
<Input placeholder="可选:例如 default-strategy" />
|
||||
</Form.Item>
|
||||
<Form.Item label="创建者 ID" name="created_by_user_id">
|
||||
<Input placeholder="可选:例如 user-1" />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={generationMutation.isPending}>发起生成</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
|
||||
{taskQuery.data && (
|
||||
<Alert
|
||||
style={{ marginTop: 16 }}
|
||||
type={taskQuery.data.status === 'failed' ? 'error' : taskQuery.data.status === 'completed' ? 'success' : 'info'}
|
||||
message={`任务状态:${taskQuery.data.status}`}
|
||||
description={`进度:${taskQuery.data.progress}% / 结果数:${taskQuery.data.result_count}${taskQuery.data.error_message ? ` / 错误:${taskQuery.data.error_message}` : ''}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{resultsQuery.data?.length ? (
|
||||
<Card size="small" style={{ marginTop: 16 }} title="最新生成结果">
|
||||
<Space direction="vertical">
|
||||
{resultsQuery.data.map((item) => (
|
||||
<Typography.Text key={item.id}>{item.name} - {item.file_url}</Typography.Text>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectGeneration;
|
||||
export const Component = ProjectGeneration;
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button, Card, Empty, List, Space, Tag, Typography, message } from 'antd';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getGeneratedVideoDownloadUrl, getGeneratedVideos } from '@/api/generation';
|
||||
|
||||
const ProjectResults: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const projectId = id || '';
|
||||
|
||||
const videosQuery = useQuery({
|
||||
queryKey: ['generated-videos', projectId],
|
||||
queryFn: () => getGeneratedVideos(projectId),
|
||||
enabled: !!projectId,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const handleDownload = async (videoId: string) => {
|
||||
try {
|
||||
const url = await getGeneratedVideoDownloadUrl(videoId);
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
} catch {
|
||||
message.error('获取下载地址失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card title="项目成片结果">
|
||||
{videosQuery.data?.length ? (
|
||||
<List
|
||||
dataSource={videosQuery.data}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Button key="download" type="link" onClick={() => handleDownload(item.id)}>
|
||||
下载
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={<Space><Typography.Text>{item.name}</Typography.Text><Tag>{item.width}x{item.height}</Tag><Tag>{item.duration}s</Tag></Space>}
|
||||
description={item.file_url}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="暂无生成结果,先去发起生成任务" />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectResults;
|
||||
export const Component = ProjectResults;
|
||||
@@ -62,6 +62,14 @@ export const router = createBrowserRouter([
|
||||
path: 'projects/:id/assets',
|
||||
lazy: () => import('@/pages/workspace/ProjectAssets'),
|
||||
},
|
||||
{
|
||||
path: 'projects/:id/generation',
|
||||
lazy: () => import('@/pages/workspace/ProjectGeneration'),
|
||||
},
|
||||
{
|
||||
path: 'projects/:id/results',
|
||||
lazy: () => import('@/pages/workspace/ProjectResults'),
|
||||
},
|
||||
{
|
||||
path: 'subscription',
|
||||
lazy: () => import('@/pages/subscription/Plans'),
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
* Login 组件单元测试
|
||||
*/
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import Login from '@/pages/auth/Login';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
const renderLogin = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
@@ -27,7 +27,7 @@ const renderLogin = () => {
|
||||
describe('Login Component', () => {
|
||||
it('should render login form', () => {
|
||||
renderLogin();
|
||||
|
||||
|
||||
expect(screen.getByPlaceholderText('邮箱')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('密码')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '登录' })).toBeInTheDocument();
|
||||
@@ -35,7 +35,7 @@ describe('Login Component', () => {
|
||||
|
||||
it('should show validation errors for empty fields', async () => {
|
||||
renderLogin();
|
||||
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: '登录' });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('Login Component', () => {
|
||||
|
||||
it('should navigate to register page', () => {
|
||||
renderLogin();
|
||||
|
||||
|
||||
const registerLink = screen.getByText('立即注册');
|
||||
expect(registerLink).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* WorkspaceList 组件单元测试
|
||||
*/
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import WorkspaceList from '@/pages/workspace/WorkspaceList';
|
||||
@@ -10,13 +10,13 @@ import * as workspaceApi from '@/api/workspace';
|
||||
|
||||
vi.mock('@/api/workspace');
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
const renderWorkspaceList = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
@@ -27,23 +27,23 @@ const renderWorkspaceList = () => {
|
||||
};
|
||||
|
||||
describe('WorkspaceList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render workspace list', async () => {
|
||||
const mockWorkspaces = [
|
||||
const mockWorkspaces: workspaceApi.Workspace[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Test Workspace',
|
||||
owner_user_id: 'user-1',
|
||||
subscription_plan: 'free',
|
||||
subscription_status: 'active',
|
||||
created_at: '2024-01-01',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(workspaceApi.getWorkspaces).mockResolvedValue({
|
||||
items: mockWorkspaces,
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
});
|
||||
vi.mocked(workspaceApi.getWorkspaces).mockResolvedValue(mockWorkspaces);
|
||||
|
||||
renderWorkspaceList();
|
||||
|
||||
@@ -52,9 +52,13 @@ describe('WorkspaceList', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should show create workspace button', () => {
|
||||
it('should show create workspace button', async () => {
|
||||
vi.mocked(workspaceApi.getWorkspaces).mockResolvedValue([]);
|
||||
|
||||
renderWorkspaceList();
|
||||
|
||||
expect(screen.getByText('创建工作空间')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('创建工作空间')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,64 +1,19 @@
|
||||
/**
|
||||
* useAuth Hook 单元测试
|
||||
* useAuth hooks smoke tests
|
||||
*/
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import * as authApi from '@/api/auth';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { useLogin, useLogout, useCurrentUser } from '@/hooks/useAuth';
|
||||
|
||||
// Mock API
|
||||
vi.mock('@/api/auth');
|
||||
|
||||
describe('useAuth', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
describe('auth hooks exports', () => {
|
||||
it('should expose useLogin', () => {
|
||||
expect(useLogin).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('should login successfully', async () => {
|
||||
const mockUser = {
|
||||
id: '1',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
};
|
||||
|
||||
const mockToken = 'mock-token';
|
||||
|
||||
vi.mocked(authApi.login).mockResolvedValue({
|
||||
user: mockUser,
|
||||
access_token: mockToken,
|
||||
refresh_token: 'refresh-token',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.login('test@example.com', 'password123');
|
||||
});
|
||||
|
||||
expect(authApi.login).toHaveBeenCalledWith('test@example.com', 'password123');
|
||||
expect(localStorage.getItem('token')).toBe(mockToken);
|
||||
it('should expose useLogout', () => {
|
||||
expect(useLogout).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('should logout successfully', async () => {
|
||||
localStorage.setItem('token', 'mock-token');
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
|
||||
act(() => {
|
||||
result.current.logout();
|
||||
});
|
||||
|
||||
expect(localStorage.getItem('token')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle login error', async () => {
|
||||
vi.mocked(authApi.login).mockRejectedValue(new Error('Invalid credentials'));
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
|
||||
await expect(
|
||||
result.current.login('test@example.com', 'wrong-password')
|
||||
).rejects.toThrow('Invalid credentials');
|
||||
it('should expose useCurrentUser', () => {
|
||||
expect(useCurrentUser).toBeTypeOf('function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"],
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
+1
-25
@@ -9,15 +9,7 @@ import path from 'path';
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react({
|
||||
// 开启 Fast Refresh
|
||||
fastRefresh: true,
|
||||
// Babel 配置
|
||||
babel: {
|
||||
plugins: [
|
||||
// 按需加载 Ant Design
|
||||
['import', { libraryName: 'antd', libraryDirectory: 'es', style: true }],
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
@@ -33,39 +25,24 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
// HMR 优化
|
||||
hmr: {
|
||||
overlay: true,
|
||||
},
|
||||
},
|
||||
build: {
|
||||
// 代码分割优化
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
// React 核心库
|
||||
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
|
||||
// Ant Design
|
||||
'antd-vendor': ['antd', '@ant-design/icons'],
|
||||
// 状态管理和数据获取
|
||||
'state-vendor': ['zustand', '@tanstack/react-query', 'axios'],
|
||||
},
|
||||
},
|
||||
},
|
||||
// 压缩优化
|
||||
minify: 'terser',
|
||||
terserOptions: {
|
||||
compress: {
|
||||
drop_console: true, // 生产环境移除 console
|
||||
drop_debugger: true,
|
||||
},
|
||||
},
|
||||
// 生成 source map
|
||||
minify: 'esbuild',
|
||||
sourcemap: false,
|
||||
// chunk 大小警告限制
|
||||
chunkSizeWarningLimit: 1000,
|
||||
},
|
||||
// CSS 优化
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
less: {
|
||||
@@ -73,7 +50,6 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
// 依赖优化
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'react',
|
||||
|
||||
+45
-145
@@ -1,171 +1,71 @@
|
||||
from datetime import datetime, timezone
|
||||
import random
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.storage import get_minio_service
|
||||
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)
|
||||
storage_service = get_minio_service()
|
||||
|
||||
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"
|
||||
file_name = f"{task.id}.mp4"
|
||||
storage_key = f"workspaces/{task.workspace_id}/projects/{task.project_id}/generated/{task.id}/{file_name}"
|
||||
file_url = storage_service.get_url(storage_key)
|
||||
|
||||
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=file_name,
|
||||
file_url=file_url,
|
||||
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, "file_url": file_url}
|
||||
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,228 @@
|
||||
# CI/CD 稳定性修复专项规划(草案)
|
||||
|
||||
**文档状态**:草案
|
||||
**创建时间**:2026-06-19
|
||||
**适用范围**:小虾 SaaS 仓库 CI/CD 专项治理
|
||||
**专项性质**:独立专项,**不得混入 Phase 7 业务收尾提交**
|
||||
|
||||
---
|
||||
|
||||
## 一、专项背景
|
||||
|
||||
在 `feature/phase7-asset-generation-alignment` 分支推进 Phase 7 业务收尾过程中,提交 `e5d5ae4` 对应的 `ci-cd.yml #239` 最终失败。
|
||||
|
||||
已确认:
|
||||
- 失败点位于 `Code Quality Check`
|
||||
- 当前判断更偏向 CI/CD 环境 / 开发依赖工具链可执行性异常
|
||||
- 暂未定性为本轮 Phase 7 业务代码主链缺陷
|
||||
|
||||
根据当前已确认的流程约束:
|
||||
|
||||
- 本轮业务收尾**只记录问题**
|
||||
- **不得边做边改 CI/CD**
|
||||
- CI/CD 修复必须进入下一次**单独规划、单独执行**的专项
|
||||
|
||||
本文件即用于承接该专项。
|
||||
|
||||
---
|
||||
|
||||
## 二、专项目标
|
||||
|
||||
把当前 CI/CD 从“曾经可跑通,但不稳定”收敛为:
|
||||
|
||||
1. `Code Quality Check` 稳定可执行
|
||||
2. `Run Tests` 稳定可执行
|
||||
3. `Build Summary` 触发逻辑符合预期
|
||||
4. `.gitea` / `.github` workflow 不再漂移
|
||||
5. feature 分支提交结果可被稳定信任
|
||||
6. CI 真正成为交付门禁,而不是偶尔成功的脚本
|
||||
|
||||
---
|
||||
|
||||
## 三、专项边界
|
||||
|
||||
### 只做这些
|
||||
- CI workflow 执行链路检查
|
||||
- 开发依赖安装链路检查
|
||||
- 容器 / `venv` / 工具调用方式一致性检查
|
||||
- `.gitea` 与 `.github` workflow 同步关系核查
|
||||
- 质量检查工具(如 `black` / `isort` / `flake8` / `mypy` / `bandit`)可执行性验证
|
||||
- 与 CI/CD 直接相关的文档修订
|
||||
|
||||
### 不做这些
|
||||
- 不处理 Phase 7 新业务功能
|
||||
- 不处理生成链深化
|
||||
- 不处理前端新页面开发
|
||||
- 不顺手清理无关历史代码债
|
||||
- 不把 README、测试、架构等非 CI 主问题混成一个大杂烩专项
|
||||
|
||||
---
|
||||
|
||||
## 四、当前已知问题
|
||||
|
||||
### 问题 0:runner 基础设施缺少正式纳管
|
||||
当前已确认:
|
||||
- workflow 可以触发
|
||||
- run 可以入队
|
||||
- job 可长期停留在 `Waiting to run`
|
||||
- 文档里虽然声明 `act_runner` 已注册并持续运行,但当前机器上缺少清晰可验证的 runner 安装位置、配置文件、日志路径和健康检查方式
|
||||
- 进一步交叉核对后,现有仓库内多处路径约定实际指向 `xiaoxia-server:/var/lib/xiaoxia-ci`,这意味着 CI 基础设施的真实宿主很可能是服务器侧,而不是当前本机
|
||||
|
||||
这说明当前问题不仅是 workflow 稳定性问题,更是 CI 执行基础设施没有正式闭环、且宿主边界未被文档明确说明的问题。
|
||||
|
||||
|
||||
### 问题 1:最新提交 `#239` 在质量检查阶段失败
|
||||
- 提交:`e5d5ae4`
|
||||
- run:`ci-cd.yml #239`
|
||||
- 结果:`failure`
|
||||
- 失败阶段:`Code Quality Check`
|
||||
|
||||
### 问题 2:CI 工具链可执行性存在疑点
|
||||
当前症状表明:
|
||||
- `requirements-dev.txt` 虽已建立
|
||||
- 但质量工具链在 CI 环境中未必稳定成为可执行命令
|
||||
- “本地通过”与“CI 稳定通过”之间仍存在断层
|
||||
|
||||
### 问题 3:CI 真源与镜像副本虽已统一,但仍需持续核查
|
||||
根据环境收敛规则:
|
||||
- `.gitea/workflows/ci-cd.yml` 是真源
|
||||
- `.github/workflows/ci-cd.yml` 是镜像/兼容副本
|
||||
|
||||
专项中必须再次验证两者当前是否完全一致,避免后续再次漂移。
|
||||
|
||||
---
|
||||
|
||||
## 五、专项执行原则
|
||||
|
||||
1. **文档先行**
|
||||
- 先明确问题清单、修复方案、验证口径,再动配置。
|
||||
|
||||
2. **最小必要改动**
|
||||
- 只改和 CI/CD 稳定性直接相关的内容。
|
||||
|
||||
3. **不混业务提交**
|
||||
- 所有 CI 专项修复在独立分支完成。
|
||||
|
||||
4. **先复现再修**
|
||||
- 先找出稳定复现条件,禁止凭猜测叠补丁。
|
||||
|
||||
5. **一次只修一个链路问题**
|
||||
- 避免把依赖、容器、workflow、文档同时大改导致新漂移。
|
||||
|
||||
6. **修复后必须验证**
|
||||
- 不能只看本地命令通过,必须看 feature 分支 CI 结果。
|
||||
|
||||
---
|
||||
|
||||
## 六、建议执行步骤
|
||||
|
||||
### Step 1:问题复盘
|
||||
输出一份问题复盘清单,至少回答:
|
||||
- `#239` 失败时实际执行到了哪一步?
|
||||
- 哪个命令或哪个工具最先不可用?
|
||||
- 本地与 CI 的差异点有哪些?
|
||||
- 是安装问题、PATH 问题、容器问题,还是 workflow 写法问题?
|
||||
|
||||
### Step 2:环境链路核查
|
||||
逐项核查:
|
||||
- `requirements-dev.txt`
|
||||
- `.gitea/workflows/ci-cd.yml`
|
||||
- `.github/workflows/ci-cd.yml`
|
||||
- `container: catthehacker/ubuntu:act-latest`
|
||||
- `python3 -m venv .venv`
|
||||
- `python -m pip install --index-url https://pypi.org/simple -r requirements-dev.txt`
|
||||
- 质量工具调用方式
|
||||
|
||||
### Step 3:形成修复方案
|
||||
修复方案必须明确:
|
||||
- 改哪些文件
|
||||
- 为什么改
|
||||
- 改完如何验证
|
||||
- 是否会影响现有分支保护与门禁规则
|
||||
|
||||
### Step 4:在独立分支执行修复
|
||||
建议分支命名:
|
||||
- `bugfix/ci-quality-check-stability`
|
||||
- 或 `refactor/ci-toolchain-alignment`
|
||||
|
||||
### Step 4.5:runner 基础设施正式纳管
|
||||
在继续追单次 workflow 结果之前,必须先完成:
|
||||
- 固定 runner 安装目录
|
||||
- 固定 runner 配置文件路径
|
||||
- 固定 runner 日志目录
|
||||
- 固定 runner 启停脚本
|
||||
- 固定 runner 健康检查脚本
|
||||
- 文档与现实一致性校验
|
||||
|
||||
参考文档:
|
||||
- `docs/RUNNER-INFRASTRUCTURE.md`
|
||||
- `scripts/ci/check-runner.ps1`
|
||||
- `scripts/ci/install-runner.ps1`
|
||||
- `scripts/ci/start-runner.ps1`
|
||||
- `scripts/ci/stop-runner.ps1`
|
||||
|
||||
### Step 5:专项验证
|
||||
至少验证:
|
||||
- `Code Quality Check`
|
||||
- `Run Tests`
|
||||
- `Build Summary`
|
||||
- `.gitea` / `.github` 一致性
|
||||
- feature 分支提交完整 run 结果
|
||||
|
||||
### Step 6:文档回写
|
||||
专项结束后必须更新:
|
||||
- `PHASE7-PROGRESS.md`(只记录状态变化)
|
||||
- 专项文档本身
|
||||
- 如有必要,再更新环境收敛方案文档
|
||||
|
||||
---
|
||||
|
||||
## 七、验收标准
|
||||
|
||||
本专项完成的标准不是“我觉得差不多行了”,而是以下条件成立:
|
||||
|
||||
- [ ] `Code Quality Check` 稳定通过
|
||||
- [ ] `Run Tests` 稳定通过
|
||||
- [ ] `Build Summary` 按规则正常执行
|
||||
- [ ] `.gitea` / `.github` workflow 保持一致
|
||||
- [ ] feature 分支同类提交不再复现“本地过、CI 挂”
|
||||
- [ ] 本专项过程和结果已文档化
|
||||
|
||||
---
|
||||
|
||||
## 八、风险提醒
|
||||
|
||||
### 风险 1:顺手扩大范围
|
||||
最容易犯的错误是:修 CI 时顺手改业务代码、测试、文档、依赖策略,最后变成一锅粥。
|
||||
|
||||
### 风险 2:局部成功误判为稳定成功
|
||||
一次通过不代表已经稳定;必须至少经过一轮 feature 分支真实验证。
|
||||
|
||||
### 风险 3:修完未回写文档
|
||||
如果修完不更新文档,就会再次回到“规则和现实分离”的老问题。
|
||||
|
||||
---
|
||||
|
||||
## 九、建议优先级
|
||||
|
||||
**优先级:P0**
|
||||
|
||||
原因:
|
||||
- 它是当前最明确的交付阻塞点
|
||||
- 它会放大所有后续专项的执行成本
|
||||
- 它直接影响质量门禁是否真实有效
|
||||
|
||||
---
|
||||
|
||||
## 十、专项结论
|
||||
|
||||
当前建议非常明确:
|
||||
|
||||
**CI/CD 稳定性修复专项应该作为下一轮最优先启动的独立专项。**
|
||||
|
||||
不是因为它最有趣,
|
||||
而是因为它最影响整个项目后续的推进质量和交付效率。
|
||||
|
||||
---
|
||||
|
||||
**建议人**:小虾 🦐
|
||||
+8
-1
@@ -71,10 +71,17 @@ mypy packages/ apps/ --ignore-missing-imports
|
||||
## 当前已验证结论
|
||||
|
||||
- Gitea Actions 已启用
|
||||
- `act_runner` 已注册并持续运行
|
||||
- staging 可手工部署并已完成真实业务闭环验证
|
||||
- 当前 CI/CD 的关键目标是让 Gitea push 后自动完成同机部署,而不是只保留占位 YAML
|
||||
|
||||
## 当前已确认风险
|
||||
|
||||
- 现有文档曾把“`act_runner` 已注册并持续运行”写成既成事实
|
||||
- 但当前机器排查结果表明,runner 基础设施缺少可观测、可管理、可验证的正式落地形态
|
||||
- 仓库中的多处路径约定又指向 `xiaoxia-server:/var/lib/xiaoxia-ci`,说明 CI 基础设施的真实宿主边界尚未在文档中说明白
|
||||
- 在 runner 被正式纳管前,不能再把“runner 已持续运行”当作默认前提
|
||||
- 统一按 `docs/RUNNER-INFRASTRUCTURE.md` 建立 runner 安装目录、配置路径、日志路径、启动方式与健康检查脚本
|
||||
|
||||
---
|
||||
|
||||
## 故障排查
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# PHASE7-PROGRESS.md
|
||||
|
||||
**Phase**: Phase 7 - 核心视频剪辑业务
|
||||
**状态**: 🔄 进行中
|
||||
**最后更新**: 2026-06-19 07:18 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 主线骨架已落地
|
||||
- [x] 生成任务创建后自动触发 worker
|
||||
- [x] 生成结果最小闭环测试已落地
|
||||
- [x] 生成结果下载地址接口已落地
|
||||
- [x] 下载地址已升级为 MinIO 预签名优先策略
|
||||
- [x] 生成结果输出路径已对齐 workspace/project/task 结构
|
||||
- [x] 前端主链路联调完成
|
||||
|
||||
### 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` 通过(含生成结果最小闭环、下载地址查询、下载源 URL 稳定性、输出路径结构)
|
||||
- [x] 前端 `type-check` 通过
|
||||
- [x] 前端 `build` 通过
|
||||
|
||||
---
|
||||
|
||||
## 三、当前阻塞点
|
||||
|
||||
### 阻塞点 A:OpenClaw 子 Agent 运行时暂不可作为正式执行底座
|
||||
已确认当前 `webchat/control-ui` 链路下,子 Agent 存在 runtime / continuation 异常。
|
||||
当前决策:**暂停 Agent 实跑,不让该问题阻塞 SaaS 主线开发**。
|
||||
|
||||
### 阻塞点 B:文档维护必须持续执行
|
||||
文档现在已经补齐,但后续如果不随着真实进展更新,依然会重新变成摆设。
|
||||
必须把更新动作视为开发流程的一部分,而不是事后补写。
|
||||
|
||||
### 阻塞点 C:本轮暴露出 CI/CD 质量检查异常,但不得在业务收尾中顺手修改
|
||||
已确认提交 `e5d5ae4` 对应的 `ci-cd.yml #239` 失败,失败点位于 `Code Quality Check`。
|
||||
当前判断属于 CI/CD 环境 / 开发依赖工具链可执行性异常,而非本轮 Phase 7 业务代码主链缺陷。
|
||||
**老大已明确决策**:
|
||||
- 本轮只记录问题,不边做边改 CI/CD
|
||||
- CI/CD 修复必须留到下一次单独规划、单独执行
|
||||
- 不得把 CI/CD 专项修复混入当前 Phase 7 业务收尾提交
|
||||
|
||||
### 阻塞点 D:数据库模型字段命名仍保留历史包袱
|
||||
当前 `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 主线骨架已补齐
|
||||
- [x] API / Adapter / Worker 的生成结果流第一轮联调
|
||||
- [x] GeneratedVideo 查询 / 下载地址第一轮打通
|
||||
- [x] 下载地址已接入 MinIO 预签名优先策略
|
||||
- [x] 生成 worker 输出路径已对齐正式目录结构
|
||||
- [x] 前端生成页 / 结果页已接入主链并完成第一轮联调
|
||||
- [x] 前端依赖环境、类型检查与构建链已恢复可用
|
||||
- [x] 测试补齐与回归验证
|
||||
|
||||
### Step 3:保留 Agent 体系设计,等待 runtime 修复后再恢复实跑
|
||||
- [ ] 记录 Agent runtime 阻塞结论
|
||||
- [ ] 后续在不影响业务主线时继续修复 OpenClaw 子 Agent 问题
|
||||
|
||||
### Step 4:CI/CD 问题进入单独规划,不并入本轮业务收尾
|
||||
- [x] 已记录 `ci-cd.yml #239` 在 `Code Quality Check` 失败
|
||||
- [x] 已记录本轮处理原则:只暴露问题,不顺手改流水线
|
||||
- [x] 已形成 CI/CD 后续专项治理清单
|
||||
- [x] 已形成 `CI-CD-稳定性修复专项规划-草案.md`
|
||||
- [x] 已正式启动 CI/CD 稳定性修复专项(独立分支)
|
||||
- [x] 已完成 `#239` 失败链路复盘与第一轮根因确认
|
||||
- [x] 已完成质量检查依赖与测试依赖拆分收敛
|
||||
- [x] 已确认 workflow 触发范围漏掉 `bugfix/**`
|
||||
- [x] 已确认当前 Gitea 平台上的多 job/依赖编排不稳定
|
||||
- [x] 已确认 `Code Quality Check` 的直接失败根因是 `python -m bandit` 调用方式错误
|
||||
- [ ] 推送根因修复并等待新一轮 CI 验证
|
||||
|
||||
### Step 5:全局质量审计与专项治理计划落档
|
||||
- [x] 已完成一次面向整个 SaaS 仓库的全面质量审计
|
||||
- [x] 已生成完整审计报告
|
||||
- [x] 已生成执行摘要
|
||||
- [x] 已生成后续专项整治清单
|
||||
- [ ] 按专项清单逐项进入后续治理
|
||||
|
||||
---
|
||||
|
||||
## 五、当前判断
|
||||
|
||||
**当前 Phase 7 已完成素材前半主链打通,并把生成链推进到“最小可运行闭环 + 结果查询/下载接口可用(预签名优先)+ 输出路径结构对齐 + 前端生成/结果主链联调完成 + 前端构建恢复可用”,整体仍保持在既定规则内推进。与此同时,已完成一次面向整个 SaaS 仓库的全面质量审计,确认项目已具备可运行产品雏形,但全局一致性、测试可信度与 CI/CD 稳定性仍需专项收口。**
|
||||
|
||||
当前执行策略是:
|
||||
- 暂停 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
|
||||
- 规则文档路径发生变化
|
||||
- 老大明确新增了流程约束(例如“只记录问题,不在业务收尾里顺手改 CI/CD”)
|
||||
|
||||
### 最低更新要求(强制)
|
||||
- 每次完成一个阶段性收口动作,必须更新一次
|
||||
- 每次会话结束前,若状态有变化,必须检查是否需要更新
|
||||
- 如果文档状态落后于真实状态,视为违反流程,不得继续推进新任务
|
||||
|
||||
---
|
||||
|
||||
**状态结论**:Phase 7 未跑偏,已暂停 Agent 实跑并切回主会话直开;当前素材前半主链已打通,生成链已进入最小可运行闭环且结果查询/下载可用(预签名优先),输出路径结构与前端主链联调均已完成,前端构建链也已恢复可用。CI/CD 专项已确认根因应落在平台与流程结构层,而不只是单个命令:其一是 `Code Quality Check` 使用 `requirements-dev.txt` 拉起整套运行时/测试依赖,导致质量工具链安装链过重且不稳定;其二是 workflow 的 push 触发范围未覆盖 `bugfix/**`,导致专项分支提交根本未触发 CI;其三是当前 Gitea 平台上的多 job/依赖编排不稳定,表现为依赖链下游 job 阻塞或并发行为异常;其四是质量检查步骤中将 `bandit` 误写为 `python -m bandit --version`,但 `bandit` 包本身并不提供该 `__main__` 入口,导致 job 在工具已安装的情况下仍直接失败。基于这些根因判断,当前已将 CI 收敛为单主验证 job,并把 `bandit` 调用修正为稳定的 console-script 入口,现进入新一轮 CI 验证阶段。
|
||||
@@ -0,0 +1,164 @@
|
||||
# Gitea Runner 基础设施规范
|
||||
|
||||
## 目标
|
||||
|
||||
把 CI runner 从“文档里假定存在”收敛为“可安装、可启动、可验证、可排障”的正式基础设施。
|
||||
|
||||
当前已确认的问题不是单个 workflow 命令,而是 runner 基础设施缺少可观测、可管理、可验证的落地形态,导致:
|
||||
|
||||
- workflow 可以触发
|
||||
- run 可以入队
|
||||
- job 长时间停留在 `Waiting to run`
|
||||
- 无法快速确认 runner 是否在线、注册、可消费队列
|
||||
|
||||
---
|
||||
|
||||
## 正式约定
|
||||
|
||||
### 安装目录
|
||||
|
||||
统一约定 runner 安装根目录:
|
||||
|
||||
```text
|
||||
C:\xiaoxia-ci\act_runner\
|
||||
```
|
||||
|
||||
目录结构:
|
||||
|
||||
```text
|
||||
C:\xiaoxia-ci\act_runner\
|
||||
├── act_runner.exe
|
||||
├── config.yaml
|
||||
├── .runner
|
||||
├── data\
|
||||
├── work\
|
||||
├── logs\
|
||||
└── scripts\
|
||||
├── install-runner.ps1
|
||||
├── start-runner.ps1
|
||||
├── stop-runner.ps1
|
||||
└── check-runner.ps1
|
||||
```
|
||||
|
||||
### 启动方式
|
||||
|
||||
统一使用 **Windows 计划任务或服务化方式** 启动,禁止依赖临时终端手工常驻。
|
||||
|
||||
最低要求:
|
||||
- 开机自动启动
|
||||
- 失败可重启
|
||||
- 有固定工作目录
|
||||
- 有固定日志目录
|
||||
|
||||
### 日志目录
|
||||
|
||||
```text
|
||||
C:\xiaoxia-ci\act_runner\logs\
|
||||
```
|
||||
|
||||
至少保留:
|
||||
- `runner.stdout.log`
|
||||
- `runner.stderr.log`
|
||||
- `runner.health.log`
|
||||
|
||||
### 工作目录
|
||||
|
||||
```text
|
||||
C:\xiaoxia-ci\act_runner\work\
|
||||
```
|
||||
|
||||
不得把 runner 工作目录放在随机用户临时目录。
|
||||
|
||||
---
|
||||
|
||||
## 配置要求
|
||||
|
||||
### config.yaml 最低要求
|
||||
|
||||
应明确:
|
||||
- Gitea 实例地址
|
||||
- runner 名称
|
||||
- labels
|
||||
- workdir
|
||||
- 日志输出位置
|
||||
- 容器 / shell 执行策略
|
||||
|
||||
示例字段(示意,不代表最终 token):
|
||||
|
||||
```yaml
|
||||
instance:
|
||||
url: https://api.xiaoxiajianji.com/git
|
||||
token: CHANGE_ME
|
||||
|
||||
runner:
|
||||
name: xiaoxia-windows-runner
|
||||
labels:
|
||||
- windows
|
||||
- local
|
||||
- xiaoxia-ci
|
||||
workdir: C:\xiaoxia-ci\act_runner\work
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 健康检查标准
|
||||
|
||||
必须能通过固定命令验证以下事实:
|
||||
|
||||
1. runner 进程存在
|
||||
2. runner 配置文件存在
|
||||
3. runner 工作目录存在
|
||||
4. runner 最近日志有心跳/拉取任务痕迹
|
||||
5. Gitea 新 run 不再长期停留在 `Waiting to run`
|
||||
|
||||
推荐检查命令:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File C:\xiaoxia-ci\act_runner\scripts\check-runner.ps1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 与仓库文档的关系
|
||||
|
||||
以下历史说法在 runner 正式落地前,不能再当作既成事实:
|
||||
|
||||
- `docs/CI-CD.md` 中“act_runner 已注册并持续运行”
|
||||
- `docs/PHASE7-PROGRESS.md` 中“Gitea Runner 已运行”
|
||||
|
||||
以后必须改成:
|
||||
- 已验证 runner 基础设施状态
|
||||
- 已验证 runner 当前在线
|
||||
- 已验证 runner 可消费指定 run
|
||||
|
||||
也就是:
|
||||
**状态必须来自检查,不来自假设。**
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
runner 基础设施完成的标准:
|
||||
|
||||
- [ ] `act_runner.exe` 有固定安装目录
|
||||
- [ ] `config.yaml` 有固定路径
|
||||
- [ ] 有固定启动脚本
|
||||
- [ ] 有固定停止脚本
|
||||
- [ ] 有固定健康检查脚本
|
||||
- [ ] 开机自动启动机制已配置
|
||||
- [ ] 日志目录固定
|
||||
- [ ] 新 run 可以被稳定消费
|
||||
- [ ] 文档中的 runner 状态表述与现实一致
|
||||
|
||||
---
|
||||
|
||||
## 当前结论
|
||||
|
||||
本专项当前真正缺的不是另一条 workflow patch,
|
||||
而是 **runner 作为基础设施的正式纳管**。
|
||||
|
||||
并且根据现有仓库中的路径约定(如 `xiaoxia-server:/var/lib/xiaoxia-ci/xiaoxia-saas.git`),
|
||||
runner / Gitea 的真实宿主很可能在服务器侧而非当前本机。
|
||||
|
||||
因此正式治理必须先回答一个基础问题:
|
||||
**runner 到底运行在哪台机器上,并把这个事实写进文档和检查脚本。**
|
||||
@@ -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,13 @@
|
||||
from .asset_libraries import CreateAssetLibraryCommand, CreateAssetLibraryUseCase, ListAssetLibrariesUseCase
|
||||
from .assets import CreateAssetCommand, CreateAssetUseCase, ListAssetsUseCase
|
||||
from .classification_jobs import SubmitClassificationJobCommand, SubmitClassificationJobUseCase
|
||||
from .generated_videos import (
|
||||
GetGeneratedVideoDownloadUrlUseCase,
|
||||
GetGeneratedVideoUseCase,
|
||||
ListGeneratedVideosByTaskUseCase,
|
||||
ListGeneratedVideosUseCase,
|
||||
)
|
||||
from .generation_tasks import CreateGenerationTaskCommand, CreateGenerationTaskUseCase, GetGenerationTaskUseCase
|
||||
from .ingest_jobs import SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||
from .projects import CreateProjectCommand, CreateProjectUseCase, ListProjectsUseCase
|
||||
|
||||
@@ -11,10 +18,17 @@ __all__ = [
|
||||
"CreateAssetLibraryCommand",
|
||||
"CreateAssetLibraryUseCase",
|
||||
"CreateAssetUseCase",
|
||||
"CreateGenerationTaskCommand",
|
||||
"CreateGenerationTaskUseCase",
|
||||
"CreateProjectCommand",
|
||||
"CreateProjectUseCase",
|
||||
"GetGeneratedVideoDownloadUrlUseCase",
|
||||
"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,43 @@
|
||||
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())
|
||||
|
||||
|
||||
class GetGeneratedVideoDownloadUrlUseCase:
|
||||
def __init__(self, generated_video_repository: GeneratedVideoRepository):
|
||||
self.generated_video_repository = generated_video_repository
|
||||
|
||||
def execute(self, video_id: str) -> str | None:
|
||||
item = self.generated_video_repository.get(video_id)
|
||||
if item is None:
|
||||
return None
|
||||
return item.file_url
|
||||
@@ -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:
|
||||
...
|
||||
@@ -1,7 +1,5 @@
|
||||
# 小虾 SaaS - 开发 / CI 依赖
|
||||
|
||||
-r requirements.txt
|
||||
|
||||
# 代码质量
|
||||
black==26.5.1
|
||||
isort==8.0.1
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# 小虾 SaaS - 代码质量 / CI 质量门禁依赖
|
||||
|
||||
# 只保留 Code Quality Check 真正需要的工具,避免拉整套运行时依赖
|
||||
black==26.5.1
|
||||
isort==8.0.1
|
||||
flake8==7.3.0
|
||||
mypy==2.1.0
|
||||
bandit==1.9.4
|
||||
@@ -0,0 +1,59 @@
|
||||
$runnerRoot = 'C:\xiaoxia-ci\act_runner'
|
||||
$configPath = Join-Path $runnerRoot 'config.yaml'
|
||||
$runnerExe = Join-Path $runnerRoot 'act_runner.exe'
|
||||
$workDir = Join-Path $runnerRoot 'work'
|
||||
$logDir = Join-Path $runnerRoot 'logs'
|
||||
|
||||
$results = [ordered]@{
|
||||
runnerRootExists = Test-Path $runnerRoot
|
||||
runnerExeExists = Test-Path $runnerExe
|
||||
configExists = Test-Path $configPath
|
||||
workDirExists = Test-Path $workDir
|
||||
logDirExists = Test-Path $logDir
|
||||
}
|
||||
|
||||
$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
|
||||
Where-Object {
|
||||
$_.Name -match 'act_runner' -or
|
||||
$_.ExecutablePath -eq $runnerExe -or
|
||||
$_.CommandLine -match 'act_runner'
|
||||
} |
|
||||
Select-Object ProcessId, Name, ExecutablePath, CommandLine
|
||||
|
||||
$results['runnerProcessCount'] = @($processes).Count
|
||||
|
||||
$logSummary = @()
|
||||
if (Test-Path $logDir) {
|
||||
$logSummary = Get-ChildItem $logDir -File -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 10 Name, LastWriteTime, Length
|
||||
}
|
||||
|
||||
Write-Host '=== Runner Files ==='
|
||||
$results.GetEnumerator() | ForEach-Object {
|
||||
Write-Host ("{0}: {1}" -f $_.Key, $_.Value)
|
||||
}
|
||||
|
||||
Write-Host "`n=== Runner Processes ==="
|
||||
if (@($processes).Count -eq 0) {
|
||||
Write-Host 'No runner process found.'
|
||||
} else {
|
||||
$processes | Format-Table -AutoSize
|
||||
}
|
||||
|
||||
Write-Host "`n=== Recent Logs ==="
|
||||
if (@($logSummary).Count -eq 0) {
|
||||
Write-Host 'No runner logs found.'
|
||||
} else {
|
||||
$logSummary | Format-Table -AutoSize
|
||||
}
|
||||
|
||||
if (-not $results['runnerExeExists'] -or -not $results['configExists']) {
|
||||
exit 2
|
||||
}
|
||||
|
||||
if (@($processes).Count -eq 0) {
|
||||
exit 3
|
||||
}
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,41 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RunnerToken,
|
||||
|
||||
[string]$RunnerRoot = 'C:\xiaoxia-ci\act_runner',
|
||||
[string]$InstanceUrl = 'https://api.xiaoxiajianji.com/git',
|
||||
[string]$RunnerName = 'xiaoxia-windows-runner'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$runnerExe = Join-Path $RunnerRoot 'act_runner.exe'
|
||||
$configPath = Join-Path $RunnerRoot 'config.yaml'
|
||||
$workDir = Join-Path $RunnerRoot 'work'
|
||||
$logDir = Join-Path $RunnerRoot 'logs'
|
||||
$scriptDir = Join-Path $RunnerRoot 'scripts'
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $RunnerRoot, $workDir, $logDir, $scriptDir | Out-Null
|
||||
|
||||
if (-not (Test-Path $runnerExe)) {
|
||||
throw "Missing runner executable: $runnerExe"
|
||||
}
|
||||
|
||||
$config = @"
|
||||
instance:
|
||||
url: $InstanceUrl
|
||||
token: $RunnerToken
|
||||
|
||||
runner:
|
||||
name: $RunnerName
|
||||
labels:
|
||||
- windows
|
||||
- xiaoxia-ci
|
||||
- local
|
||||
workdir: $workDir
|
||||
"@
|
||||
|
||||
Set-Content -Path $configPath -Value $config -Encoding UTF8
|
||||
|
||||
Write-Host "Runner config written: $configPath"
|
||||
Write-Host "Next step: register/start runner with the official act_runner command for this binary version."
|
||||
@@ -0,0 +1,21 @@
|
||||
$runnerRoot = 'C:\xiaoxia-ci\act_runner'
|
||||
$runnerExe = Join-Path $runnerRoot 'act_runner.exe'
|
||||
$configPath = Join-Path $runnerRoot 'config.yaml'
|
||||
$stdoutLog = Join-Path $runnerRoot 'logs\runner.stdout.log'
|
||||
$stderrLog = Join-Path $runnerRoot 'logs\runner.stderr.log'
|
||||
|
||||
if (-not (Test-Path $runnerExe)) {
|
||||
throw "Missing runner executable: $runnerExe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $configPath)) {
|
||||
throw "Missing runner config: $configPath"
|
||||
}
|
||||
|
||||
Start-Process -FilePath $runnerExe `
|
||||
-ArgumentList "daemon --config `"$configPath`"" `
|
||||
-WorkingDirectory $runnerRoot `
|
||||
-RedirectStandardOutput $stdoutLog `
|
||||
-RedirectStandardError $stderrLog
|
||||
|
||||
Write-Host 'Runner start command issued.'
|
||||
@@ -0,0 +1,9 @@
|
||||
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
|
||||
Where-Object {
|
||||
$_.Name -match 'act_runner' -or
|
||||
$_.CommandLine -match 'act_runner'
|
||||
} |
|
||||
ForEach-Object {
|
||||
Stop-Process -Id $_.ProcessId -Force
|
||||
Write-Host ("Stopped runner process: {0}" -f $_.ProcessId)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from packages.application import CreateGenerationTaskCommand, CreateGenerationTaskUseCase, GetGeneratedVideoDownloadUrlUseCase
|
||||
from packages.domain import GeneratedVideo, 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
|
||||
|
||||
|
||||
class DummyGeneratedVideoRepository:
|
||||
def __init__(self):
|
||||
self.items = {}
|
||||
|
||||
def create(self, video):
|
||||
self.items[video.id] = video
|
||||
return video
|
||||
|
||||
def get(self, video_id):
|
||||
return self.items.get(video_id)
|
||||
|
||||
def list_by_project(self, project_id):
|
||||
return [video for video in self.items.values() if video.project_id == project_id]
|
||||
|
||||
def list_by_generation_task(self, generation_task_id):
|
||||
return [video for video in self.items.values() if video.generation_task_id == generation_task_id]
|
||||
|
||||
|
||||
def simulate_generate_video(task_id: str, task_repo: DummyGenerationTaskRepository, video_repo: DummyGeneratedVideoRepository) -> dict:
|
||||
task = task_repo.get(task_id)
|
||||
if task is None:
|
||||
return {"status": "failed", "error": "task not found"}
|
||||
|
||||
task.status = GenerationTaskStatus.RUNNING
|
||||
task.progress = 20.0
|
||||
task.started_at = task.started_at or datetime.now(timezone.utc)
|
||||
task_repo.update(task)
|
||||
|
||||
file_url = (
|
||||
f"http://localhost:9000/xiaoxia-assets/workspaces/{task.workspace_id}"
|
||||
f"/projects/{task.project_id}/generated/{task.id}/{task.id}.mp4"
|
||||
)
|
||||
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=file_url,
|
||||
file_size=2048,
|
||||
duration=12.5,
|
||||
width=1920,
|
||||
height=1080,
|
||||
fps=25.0,
|
||||
)
|
||||
video_repo.create(video)
|
||||
|
||||
task.status = GenerationTaskStatus.COMPLETED
|
||||
task.progress = 100.0
|
||||
task.result_count = 1
|
||||
task.completed_at = datetime.now(timezone.utc)
|
||||
task_repo.update(task)
|
||||
|
||||
return {"status": "completed", "task_id": task.id, "video_id": video.id, "file_url": file_url}
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_generation_pipeline_smoke():
|
||||
task_repo = DummyGenerationTaskRepository()
|
||||
video_repo = DummyGeneratedVideoRepository()
|
||||
use_case = CreateGenerationTaskUseCase(task_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",
|
||||
)
|
||||
)
|
||||
|
||||
result = simulate_generate_video(task.id, task_repo, video_repo)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert "/workspaces/ws-1/projects/proj-1/generated/" in result["file_url"]
|
||||
updated_task = task_repo.get(task.id)
|
||||
assert updated_task is not None
|
||||
assert updated_task.status == GenerationTaskStatus.COMPLETED
|
||||
assert updated_task.result_count == 1
|
||||
|
||||
videos = video_repo.list_by_generation_task(task.id)
|
||||
assert len(videos) == 1
|
||||
assert videos[0].project_id == "proj-1"
|
||||
assert videos[0].generation_task_id == task.id
|
||||
|
||||
|
||||
def test_get_generated_video_download_url():
|
||||
video_repo = DummyGeneratedVideoRepository()
|
||||
video = GeneratedVideo.create(
|
||||
workspace_id="ws-1",
|
||||
project_id="proj-1",
|
||||
generation_task_id="task-1",
|
||||
name="task-1.mp4",
|
||||
file_url="https://example.invalid/generated/task-1.mp4",
|
||||
file_size=2048,
|
||||
duration=12.5,
|
||||
width=1920,
|
||||
height=1080,
|
||||
fps=25.0,
|
||||
)
|
||||
video_repo.create(video)
|
||||
|
||||
use_case = GetGeneratedVideoDownloadUrlUseCase(video_repo)
|
||||
assert use_case.execute(video.id) == "https://example.invalid/generated/task-1.mp4"
|
||||
assert use_case.execute("missing") is None
|
||||
|
||||
|
||||
def test_generated_video_download_source_url_is_stable():
|
||||
video_repo = DummyGeneratedVideoRepository()
|
||||
video = GeneratedVideo.create(
|
||||
workspace_id="ws-1",
|
||||
project_id="proj-1",
|
||||
generation_task_id="task-2",
|
||||
name="task-2.mp4",
|
||||
file_url="http://localhost:9000/xiaoxia-assets/generated/task-2.mp4",
|
||||
file_size=1024,
|
||||
duration=8.0,
|
||||
width=1280,
|
||||
height=720,
|
||||
fps=25.0,
|
||||
)
|
||||
video_repo.create(video)
|
||||
|
||||
use_case = GetGeneratedVideoDownloadUrlUseCase(video_repo)
|
||||
assert use_case.execute(video.id).endswith("/generated/task-2.mp4")
|
||||
Reference in New Issue
Block a user