Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cd54beb1b | |||
| 0d98d1ecca | |||
| 17fbae13a8 | |||
| 41e421b44b | |||
| 9f86bd40ca | |||
| 881eea9195 | |||
| 4faceb8093 |
Executable → Regular
+70
-10
@@ -92,9 +92,9 @@ jobs:
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python3 -m pip install --break-system-packages -q -r requirements-base.txt
|
||||
python3 -m pip install --break-system-packages -q -r requirements.txt
|
||||
python3 -m pip install --break-system-packages -q -r requirements-dev.txt
|
||||
python3 -m pip install -q -r requirements-base.txt
|
||||
python3 -m pip install -q -r requirements.txt
|
||||
python3 -m pip install -q -r requirements-dev.txt
|
||||
python3 -m black --version
|
||||
python3 -m isort --version-number
|
||||
python3 -m flake8 --version
|
||||
@@ -169,6 +169,10 @@ jobs:
|
||||
|
||||
env:
|
||||
USE_IN_MEMORY_DB: "true"
|
||||
OSS_ACCESS_KEY_ID: placeholder
|
||||
OSS_ACCESS_KEY_SECRET: placeholder
|
||||
OSS_BUCKET_NAME: xiaoxia-autocut
|
||||
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -217,13 +221,39 @@ jobs:
|
||||
tar.extract(member, '.')
|
||||
PY
|
||||
|
||||
- name: Install ffmpeg
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
if command -v ffmpeg > /dev/null 2>&1; then
|
||||
echo "ffmpeg already installed: $(ffmpeg -version | head -1)"
|
||||
exit 0
|
||||
fi
|
||||
if command -v apt-get > /dev/null 2>&1; then
|
||||
apt-get update -qq && apt-get install -y -qq ffmpeg
|
||||
elif command -v yum > /dev/null 2>&1; then
|
||||
yum install -y -q epel-release 2>/dev/null
|
||||
yum install -y -q ffmpeg 2>/dev/null
|
||||
if [ $? -ne 0 ] && command -v dnf > /dev/null 2>&1; then
|
||||
dnf install -y -q --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E %rhel).noarch.rpm 2>/dev/null
|
||||
dnf install -y -q ffmpeg 2>/dev/null
|
||||
fi
|
||||
elif command -v dnf > /dev/null 2>&1; then
|
||||
dnf install -y -q ffmpeg 2>/dev/null
|
||||
fi
|
||||
if command -v ffmpeg > /dev/null 2>&1; then
|
||||
echo "ffmpeg installed successfully: $(ffmpeg -version | head -1)"
|
||||
else
|
||||
echo "Warning: ffmpeg installation failed or not available, some tests may be skipped"
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python3 -m pip install --break-system-packages -q -r requirements-base.txt
|
||||
python3 -m pip install --break-system-packages -q -r requirements.txt
|
||||
python3 -m pip install --break-system-packages -q -r requirements-dev.txt
|
||||
python3 -m pip install -q -r requirements-base.txt
|
||||
python3 -m pip install -q -r requirements.txt
|
||||
python3 -m pip install -q -r requirements-dev.txt
|
||||
pytest --version
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
@@ -260,6 +290,10 @@ jobs:
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas
|
||||
USE_IN_MEMORY_DB: "false"
|
||||
OSS_ACCESS_KEY_ID: placeholder
|
||||
OSS_ACCESS_KEY_SECRET: placeholder
|
||||
OSS_BUCKET_NAME: xiaoxia-autocut
|
||||
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -320,11 +354,37 @@ jobs:
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
python3 -m pip install --break-system-packages -q -r requirements-base.txt
|
||||
python3 -m pip install --break-system-packages -q -r requirements.txt
|
||||
python3 -m pip install --break-system-packages -q -r requirements-dev.txt
|
||||
python3 -m pip install -q -r requirements-base.txt
|
||||
python3 -m pip install -q -r requirements.txt
|
||||
python3 -m pip install -q -r requirements-dev.txt
|
||||
pytest --version
|
||||
|
||||
- name: Install ffmpeg
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
if command -v ffmpeg > /dev/null 2>&1; then
|
||||
echo "ffmpeg already installed: $(ffmpeg -version | head -1)"
|
||||
exit 0
|
||||
fi
|
||||
if command -v apt-get > /dev/null 2>&1; then
|
||||
apt-get update -qq && apt-get install -y -qq ffmpeg
|
||||
elif command -v yum > /dev/null 2>&1; then
|
||||
yum install -y -q epel-release 2>/dev/null
|
||||
yum install -y -q ffmpeg 2>/dev/null
|
||||
if [ $? -ne 0 ] && command -v dnf > /dev/null 2>&1; then
|
||||
dnf install -y -q --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E %rhel).noarch.rpm 2>/dev/null
|
||||
dnf install -y -q ffmpeg 2>/dev/null
|
||||
fi
|
||||
elif command -v dnf > /dev/null 2>&1; then
|
||||
dnf install -y -q ffmpeg 2>/dev/null
|
||||
fi
|
||||
if command -v ffmpeg > /dev/null 2>&1; then
|
||||
echo "ffmpeg installed successfully: $(ffmpeg -version | head -1)"
|
||||
else
|
||||
echo "Warning: ffmpeg installation failed or not available, some tests may be skipped"
|
||||
fi
|
||||
|
||||
- name: Start Redis
|
||||
shell: sh
|
||||
run: |
|
||||
@@ -394,7 +454,7 @@ jobs:
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
pip install --break-system-packages -q pytest-rerunfailures
|
||||
python3 -m pip install -q pytest-rerunfailures
|
||||
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run --append \
|
||||
--source=apps/api/app,packages \
|
||||
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
|
||||
|
||||
Regular → Executable
+5
-6
@@ -12,7 +12,7 @@ from app.schemas.asset_library import (
|
||||
EnsureDefaultLibraryRequest,
|
||||
ListAssetLibrariesResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
|
||||
from packages.application import (
|
||||
CreateAssetLibraryCommand,
|
||||
@@ -146,7 +146,7 @@ def ensure_default_library(
|
||||
return _to_asset_library_response(created)
|
||||
|
||||
|
||||
@router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
|
||||
def delete_asset_library(
|
||||
library_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -163,11 +163,10 @@ def delete_asset_library(
|
||||
# 权限校验:检查用户是否有项目访问权限
|
||||
check_project_access(library.project_id, authenticated_user.user.id, project_repository)
|
||||
|
||||
# 删除库内所有素材(无 FK 级联,需手动清理)
|
||||
# 删除库内所有素材(硬删除,素材库已删除,无需保留软删除状态)
|
||||
assets_in_library = asset_repository.find_by_library(library_id)
|
||||
if assets_in_library:
|
||||
asset_ids_to_delete = [a.id for a in assets_in_library]
|
||||
asset_repository.batch_delete(asset_ids_to_delete)
|
||||
for asset in assets_in_library:
|
||||
asset_repository.delete(asset.id)
|
||||
|
||||
# 删除素材库本身
|
||||
asset_library_repository.delete(library_id)
|
||||
|
||||
Regular → Executable
+144
-19
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.api.routes._helpers import check_project_access
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
@@ -11,15 +12,18 @@ from app.dependencies import (
|
||||
)
|
||||
from app.schemas.asset import (
|
||||
AssetResponse,
|
||||
BatchClassifyRequest,
|
||||
BatchDeleteRequest,
|
||||
BatchDeleteResponse,
|
||||
BatchMarkRequest,
|
||||
BatchOperationResponse,
|
||||
BatchTagRequest,
|
||||
CreateAssetRequest,
|
||||
ListAssetsResponse,
|
||||
UpdateAssetRequest,
|
||||
UpdateAssetReviewRequest,
|
||||
)
|
||||
from app.schemas.tag import TagAssetsRequest
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
|
||||
from packages.application import (
|
||||
CreateAssetCommand,
|
||||
@@ -27,8 +31,6 @@ from packages.application import (
|
||||
)
|
||||
from packages.domain import AssetStatus, ClassificationStatus
|
||||
|
||||
from app.api.routes._helpers import check_project_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -74,7 +76,6 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.get("", response_model=ListAssetsResponse)
|
||||
def list_assets(
|
||||
library_id: Optional[str] = Query(None),
|
||||
@@ -262,33 +263,157 @@ def update_asset_review_status(
|
||||
return _to_asset_response(updated)
|
||||
|
||||
|
||||
@router.post("/batch-delete", response_model=BatchDeleteResponse)
|
||||
@router.post("/batch-delete", response_model=BatchOperationResponse)
|
||||
def batch_delete_assets(
|
||||
request: BatchDeleteRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> BatchDeleteResponse:
|
||||
"""批量删除素材(配音素材等),需逐项校验项目权限。"""
|
||||
) -> BatchOperationResponse:
|
||||
"""批量删除素材(软删除,标记 status=deleted),需逐项校验项目权限。"""
|
||||
user_id = authenticated_user.user.id
|
||||
deleted_ids: list[str] = []
|
||||
failed_ids: list[str] = []
|
||||
success_ids: list[str] = []
|
||||
failed_details: dict[str, str] = {}
|
||||
|
||||
for asset_id in request.ids:
|
||||
for asset_id in request.asset_ids:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
failed_ids.append(asset_id)
|
||||
failed_details[asset_id] = "not_found"
|
||||
continue
|
||||
try:
|
||||
check_project_access(item.project_id, user_id, project_repository)
|
||||
deleted_ids.append(asset_id)
|
||||
success_ids.append(asset_id)
|
||||
except HTTPException:
|
||||
failed_ids.append(asset_id)
|
||||
failed_details[asset_id] = "access_denied"
|
||||
|
||||
if deleted_ids:
|
||||
asset_repository.batch_delete(deleted_ids)
|
||||
if success_ids:
|
||||
asset_repository.batch_delete(success_ids)
|
||||
|
||||
return BatchDeleteResponse(deleted_count=len(deleted_ids), failed_ids=failed_ids)
|
||||
return BatchOperationResponse(
|
||||
success_count=len(success_ids),
|
||||
failed_ids=list(failed_details.keys()),
|
||||
failed_details=failed_details,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/batch-tag", response_model=BatchOperationResponse)
|
||||
def batch_tag_assets(
|
||||
request: BatchTagRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
tag_repository: Any = Depends(get_tag_repository),
|
||||
) -> BatchOperationResponse:
|
||||
"""批量打标签(添加或替换模式),需逐项校验项目权限和标签权限。"""
|
||||
user_id = authenticated_user.user.id
|
||||
success_ids: list[str] = []
|
||||
failed_details: dict[str, str] = {}
|
||||
|
||||
# 校验标签存在且属于当前用户
|
||||
for tag_id in request.tag_ids:
|
||||
tag = tag_repository.get(tag_id)
|
||||
if tag is None:
|
||||
return BatchOperationResponse(
|
||||
success_count=0,
|
||||
failed_ids=list(request.asset_ids),
|
||||
failed_details={aid: f"tag_not_found:{tag_id}" for aid in request.asset_ids},
|
||||
)
|
||||
if tag.user_id != user_id:
|
||||
return BatchOperationResponse(
|
||||
success_count=0,
|
||||
failed_ids=list(request.asset_ids),
|
||||
failed_details={aid: f"tag_access_denied:{tag_id}" for aid in request.asset_ids},
|
||||
)
|
||||
|
||||
# 校验素材权限
|
||||
for asset_id in request.asset_ids:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
failed_details[asset_id] = "not_found"
|
||||
continue
|
||||
try:
|
||||
check_project_access(item.project_id, user_id, project_repository)
|
||||
success_ids.append(asset_id)
|
||||
except HTTPException:
|
||||
failed_details[asset_id] = "access_denied"
|
||||
|
||||
if success_ids:
|
||||
if request.mode == "replace":
|
||||
asset_repository.batch_replace_tags(success_ids, request.tag_ids)
|
||||
else:
|
||||
asset_repository.batch_add_tags(success_ids, request.tag_ids)
|
||||
|
||||
return BatchOperationResponse(
|
||||
success_count=len(success_ids),
|
||||
failed_ids=list(failed_details.keys()),
|
||||
failed_details=failed_details,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/batch-classify", response_model=BatchOperationResponse)
|
||||
def batch_classify_assets(
|
||||
request: BatchClassifyRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> BatchOperationResponse:
|
||||
"""批量修改素材内容分类(person/scenic/product等),存在metadata.category中。"""
|
||||
user_id = authenticated_user.user.id
|
||||
success_ids: list[str] = []
|
||||
failed_details: dict[str, str] = {}
|
||||
|
||||
for asset_id in request.asset_ids:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
failed_details[asset_id] = "not_found"
|
||||
continue
|
||||
try:
|
||||
check_project_access(item.project_id, user_id, project_repository)
|
||||
success_ids.append(asset_id)
|
||||
except HTTPException:
|
||||
failed_details[asset_id] = "access_denied"
|
||||
|
||||
if success_ids:
|
||||
asset_repository.batch_update_metadata(success_ids, {"category": request.category})
|
||||
|
||||
return BatchOperationResponse(
|
||||
success_count=len(success_ids),
|
||||
failed_ids=list(failed_details.keys()),
|
||||
failed_details=failed_details,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/batch-mark", response_model=BatchOperationResponse)
|
||||
def batch_mark_assets(
|
||||
request: BatchMarkRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> BatchOperationResponse:
|
||||
"""批量设置智能视图标记(recommended/caution/high_risk),存在metadata.smart_view中。"""
|
||||
user_id = authenticated_user.user.id
|
||||
success_ids: list[str] = []
|
||||
failed_details: dict[str, str] = {}
|
||||
|
||||
for asset_id in request.asset_ids:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
failed_details[asset_id] = "not_found"
|
||||
continue
|
||||
try:
|
||||
check_project_access(item.project_id, user_id, project_repository)
|
||||
success_ids.append(asset_id)
|
||||
except HTTPException:
|
||||
failed_details[asset_id] = "access_denied"
|
||||
|
||||
if success_ids:
|
||||
asset_repository.batch_update_metadata(success_ids, {"smart_view": request.smart_view})
|
||||
|
||||
return BatchOperationResponse(
|
||||
success_count=len(success_ids),
|
||||
failed_ids=list(failed_details.keys()),
|
||||
failed_details=failed_details,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{asset_id}", response_model=AssetResponse)
|
||||
@@ -330,7 +455,7 @@ def update_asset(
|
||||
return _to_asset_response(updated)
|
||||
|
||||
|
||||
@router.delete("/{asset_id}", status_code=204)
|
||||
@router.delete("/{asset_id}", status_code=204, response_class=Response)
|
||||
def delete_asset(
|
||||
asset_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -369,7 +494,7 @@ def tag_asset(
|
||||
return _to_asset_response(updated)
|
||||
|
||||
|
||||
@router.delete("/{asset_id}/tags/{tag_id}", status_code=204)
|
||||
@router.delete("/{asset_id}/tags/{tag_id}", status_code=204, response_class=Response)
|
||||
def untag_asset(
|
||||
asset_id: str,
|
||||
tag_id: str,
|
||||
|
||||
@@ -13,6 +13,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.api.routes._helpers import require_project_and_library
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
@@ -34,8 +35,6 @@ from fastapi.params import File
|
||||
|
||||
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||
|
||||
from app.api.routes._helpers import require_project_and_library
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -239,7 +239,7 @@ def get_duplication_detail(
|
||||
return _to_detail_response(record)
|
||||
|
||||
|
||||
@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
def delete_duplication_record(
|
||||
record_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -257,7 +257,7 @@ def delete_duplication_record(
|
||||
|
||||
use_case = DeleteDuplicationRecordUseCase(duplication_repository)
|
||||
use_case.execute(record_id)
|
||||
return Response(status_code=204)
|
||||
return
|
||||
|
||||
|
||||
@router.post("/records/{record_id}/retry", response_model=DuplicationUploadResponse)
|
||||
|
||||
Executable → Regular
+4
-3
@@ -25,14 +25,15 @@ from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ._helpers import check_project_access
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
|
||||
from ._helpers import check_project_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -421,7 +422,7 @@ def update_plan(
|
||||
return _to_response(result)
|
||||
|
||||
|
||||
@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
def delete_plan(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
|
||||
@@ -15,8 +15,8 @@ from app.api.routes._helpers import check_project_access
|
||||
from app.api.routes.edit_plans import (
|
||||
ClipStatusItem,
|
||||
EditPlanGenerateResponse,
|
||||
EditPlanGenerationStatusResponse,
|
||||
EditPlanGenerationsResponse,
|
||||
EditPlanGenerationStatusResponse,
|
||||
)
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
|
||||
@@ -173,12 +173,12 @@ async def update_feature_flag(
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}")
|
||||
|
||||
|
||||
@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
|
||||
@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
async def delete_feature_flag(
|
||||
name: str,
|
||||
_: bool = Depends(_verify_internal_api_key),
|
||||
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||||
) -> None:
|
||||
) :
|
||||
"""删除 Feature Flag。
|
||||
|
||||
只允许删除 ALLOWED_FLAGS 列表中的 flag。
|
||||
@@ -188,7 +188,7 @@ async def delete_feature_flag(
|
||||
try:
|
||||
deleted = store.delete(name)
|
||||
logger.info("Feature flag deleted: name=%s deleted=%s", name, deleted)
|
||||
return None
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.error("Failed to delete feature flag %s: %s", name, exc)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}")
|
||||
|
||||
Executable → Regular
+1
-2
@@ -3,6 +3,7 @@ import random
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.api.routes._helpers import check_project_access
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.core.task_enqueue import (
|
||||
@@ -31,8 +32,6 @@ from app.schemas.generation_task import (
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.api.routes._helpers import check_project_access
|
||||
|
||||
from packages.application import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
|
||||
Executable → Regular
+3
-3
@@ -7,7 +7,7 @@ from app.schemas.project import (
|
||||
ListProjectsResponse,
|
||||
ProjectResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
|
||||
from packages.application import (
|
||||
CreateProjectCommand,
|
||||
@@ -72,7 +72,7 @@ def create_project(
|
||||
return _to_project_response(project)
|
||||
|
||||
|
||||
@router.delete("/{project_id}")
|
||||
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
def delete_project(
|
||||
project_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -88,4 +88,4 @@ def delete_project(
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
return {"message": "Project deleted successfully"}
|
||||
return
|
||||
|
||||
@@ -10,7 +10,7 @@ from app.schemas.tag import (
|
||||
ListTagsResponse,
|
||||
TagResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
|
||||
from packages.domain import Tag
|
||||
|
||||
@@ -52,7 +52,7 @@ def create_tag(
|
||||
return TagResponse(id=created.id, name=created.name, created_at=created.created_at)
|
||||
|
||||
|
||||
@router.delete("/{tag_id}", status_code=204)
|
||||
@router.delete("/{tag_id}", status_code=204, response_class=Response)
|
||||
def delete_tag(
|
||||
tag_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
|
||||
@@ -206,7 +206,7 @@ def update_template(
|
||||
return _to_response(template)
|
||||
|
||||
|
||||
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
def delete_template(
|
||||
template_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -217,7 +217,7 @@ def delete_template(
|
||||
deleted = use_case.execute(template_id, user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
return Response(status_code=204)
|
||||
return
|
||||
|
||||
|
||||
@router.post("/{template_id}/toggle-favorite", response_model=ToggleFavoriteResponse)
|
||||
@@ -307,7 +307,7 @@ def create_category(
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
def delete_category(
|
||||
category_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -318,4 +318,4 @@ def delete_category(
|
||||
deleted = use_case.execute(category_id, user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
|
||||
return Response(status_code=204)
|
||||
return
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from app.api.routes._helpers import get_user_plan
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_user_repository
|
||||
from app.schemas.title_library import (
|
||||
@@ -28,8 +29,6 @@ from packages.application.title_library.use_cases import (
|
||||
)
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
from app.api.routes._helpers import get_user_plan
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -138,7 +137,7 @@ def update_title(
|
||||
return _to_response(item)
|
||||
|
||||
|
||||
@router.delete("/{title_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
@router.delete("/{title_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
def delete_title(
|
||||
title_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -149,4 +148,4 @@ def delete_title(
|
||||
deleted = use_case.execute(title_id, user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
|
||||
return Response(status_code=204)
|
||||
return
|
||||
|
||||
@@ -241,7 +241,7 @@ def get_tts_job_status(
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
@router.delete("/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
def delete_tts_job(
|
||||
job_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -253,7 +253,7 @@ def delete_tts_job(
|
||||
deleted = use_case.execute(job_id, user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
|
||||
return Response(status_code=204)
|
||||
return
|
||||
|
||||
|
||||
@router.post(
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.api.routes._helpers import require_project_and_library
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.config import get_settings
|
||||
from app.core.celery_app import celery_app
|
||||
@@ -23,8 +24,6 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, s
|
||||
|
||||
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||
|
||||
from app.api.routes._helpers import require_project_and_library
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -172,6 +172,7 @@ def get_voice_clone_status(
|
||||
"/{clone_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_model=None,
|
||||
response_class=Response,
|
||||
)
|
||||
def delete_voice_clone(
|
||||
clone_id: str,
|
||||
@@ -184,7 +185,7 @@ def delete_voice_clone(
|
||||
deleted = use_case.execute(clone_id, user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
|
||||
return Response(status_code=204)
|
||||
return
|
||||
|
||||
|
||||
@router.post("/{clone_id}/retry", response_model=VoiceCloneProfileResponse)
|
||||
|
||||
Executable → Regular
+3
-4
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
from app.api.routes._helpers import get_user_plan
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_audio_url_signer, get_db_session, get_user_repository
|
||||
from app.schemas.voice import (
|
||||
@@ -39,8 +40,6 @@ from packages.application.voice_library.use_cases import (
|
||||
from packages.domain.preset_voices import PRESET_VOICES
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
from app.api.routes._helpers import get_user_plan
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -323,7 +322,7 @@ def update_voice(
|
||||
return _to_response(item, sign_url)
|
||||
|
||||
|
||||
@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
def delete_voice(
|
||||
voice_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -334,4 +333,4 @@ def delete_voice(
|
||||
deleted = use_case.execute(voice_id, user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
|
||||
return Response(status_code=204)
|
||||
return
|
||||
|
||||
@@ -5,8 +5,8 @@ This module keeps old import paths working so existing code
|
||||
does not need to change.
|
||||
"""
|
||||
|
||||
from packages.shared.storage import SharedStorageService as OSSStorageService
|
||||
from packages.shared.storage import (
|
||||
SharedStorageService as OSSStorageService,
|
||||
get_shared_storage_service,
|
||||
get_storage_service,
|
||||
)
|
||||
|
||||
Regular → Executable
+34
-6
@@ -54,17 +54,45 @@ class AssetResponse(BaseModel):
|
||||
tag_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
MAX_BATCH_SIZE = 200
|
||||
|
||||
|
||||
class BatchDeleteRequest(BaseModel):
|
||||
"""批量删除请求。"""
|
||||
"""批量删除请求(软删除)。"""
|
||||
|
||||
ids: list[str] = Field(..., min_length=1, max_length=100, description="要删除的素材 ID 列表")
|
||||
asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="要删除的素材 ID 列表")
|
||||
|
||||
|
||||
class BatchDeleteResponse(BaseModel):
|
||||
"""批量删除响应。"""
|
||||
class BatchOperationResponse(BaseModel):
|
||||
"""批量操作通用响应。"""
|
||||
|
||||
deleted_count: int = Field(..., ge=0, description="实际删除数量")
|
||||
failed_ids: list[str] = Field(default_factory=list, description="删除失败的 ID 列表")
|
||||
success_count: int = Field(..., ge=0, description="成功数量")
|
||||
failed_ids: list[str] = Field(default_factory=list, description="失败的 ID 列表")
|
||||
failed_details: dict[str, str] = Field(default_factory=dict, description="失败详情 {asset_id: reason}")
|
||||
|
||||
|
||||
class BatchTagRequest(BaseModel):
|
||||
"""批量打标签请求。"""
|
||||
|
||||
asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表")
|
||||
tag_ids: list[str] = Field(..., min_length=1, max_length=50, description="标签 ID 列表")
|
||||
mode: str = Field(default="add", pattern="^(add|replace)$", description="add=添加合并,replace=全量替换")
|
||||
|
||||
|
||||
class BatchClassifyRequest(BaseModel):
|
||||
"""批量修改分类请求。"""
|
||||
|
||||
asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表")
|
||||
category: str = Field(..., min_length=1, max_length=50, description="内容分类,如 person/scenic/product")
|
||||
|
||||
|
||||
class BatchMarkRequest(BaseModel):
|
||||
"""批量设置智能视图标记请求。"""
|
||||
|
||||
asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表")
|
||||
smart_view: str = Field(
|
||||
..., pattern="^(recommended|caution|high_risk)$", description="智能视图标记:recommended/caution/high_risk"
|
||||
)
|
||||
|
||||
|
||||
class ListAssetsResponse(BaseModel):
|
||||
|
||||
Executable → Regular
-1
@@ -13,7 +13,6 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
|
||||
from packages.application.jobs import (
|
||||
CancelJobUseCase,
|
||||
CompleteJobCommand,
|
||||
|
||||
@@ -26,6 +26,9 @@ export default defineConfig({
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
channel: process.env.E2E_BROWSER_CHANNEL || "msedge",
|
||||
launchOptions: {
|
||||
args: ["--disable-gpu", "--disable-software-rasterizer"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -51,6 +54,9 @@ export default defineConfig({
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
channel: process.env.E2E_BROWSER_CHANNEL || "msedge",
|
||||
launchOptions: {
|
||||
args: ["--disable-gpu", "--disable-software-rasterizer"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -13,11 +13,7 @@ import "./accounts.css";
|
||||
|
||||
/* ── 类型定义 ───────────────────────────────────────────── */
|
||||
|
||||
export type PlatformId =
|
||||
| "douyin"
|
||||
| "kuaishou"
|
||||
| "xiaohongshu"
|
||||
| "wechat";
|
||||
export type PlatformId = "douyin" | "kuaishou" | "xiaohongshu" | "wechat";
|
||||
|
||||
export interface Platform {
|
||||
id: PlatformId;
|
||||
|
||||
@@ -39,7 +39,11 @@ const Dashboard: React.FC = () => {
|
||||
<section className="xx-dashboard-section">
|
||||
<div className="xx-dashboard-section-header">
|
||||
<h3>最近任务</h3>
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={() => navigate("/app/history")}>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => navigate("/app/history")}
|
||||
>
|
||||
查看全部
|
||||
</Button>
|
||||
</div>
|
||||
@@ -51,7 +55,10 @@ const Dashboard: React.FC = () => {
|
||||
</section>
|
||||
|
||||
{/* 使用统计 */}
|
||||
<section className="xx-dashboard-section" style={{ marginTop: "var(--space-md)" }}>
|
||||
<section
|
||||
className="xx-dashboard-section"
|
||||
style={{ marginTop: "var(--space-md)" }}
|
||||
>
|
||||
<div className="xx-dashboard-section-header">
|
||||
<h3>使用统计</h3>
|
||||
</div>
|
||||
@@ -66,13 +73,18 @@ const Dashboard: React.FC = () => {
|
||||
</section>
|
||||
|
||||
{/* 公告 */}
|
||||
<section className="xx-dashboard-section" style={{ marginTop: "var(--space-md)" }}>
|
||||
<section
|
||||
className="xx-dashboard-section"
|
||||
style={{ marginTop: "var(--space-md)" }}
|
||||
>
|
||||
<div className="xx-dashboard-section-header">
|
||||
<h3>公告</h3>
|
||||
</div>
|
||||
<div className="xx-announcement-list">
|
||||
<div className="xx-announcement-item">
|
||||
<span className="xx-announcement-tag xx-announcement-tag--notice">官方</span>
|
||||
<span className="xx-announcement-tag xx-announcement-tag--notice">
|
||||
官方
|
||||
</span>
|
||||
<div className="xx-announcement-content">
|
||||
<h4>欢迎使用小应 SaaS 平台</h4>
|
||||
<time>当前为演示版本,部分功能正在开发中。</time>
|
||||
|
||||
@@ -416,7 +416,6 @@ const TitleLibrary: React.FC = () => {
|
||||
[deleteMutation],
|
||||
);
|
||||
|
||||
|
||||
/* 新建标题 */
|
||||
const handleCreateTitle = () => {
|
||||
if (!newTitleContent.trim()) {
|
||||
@@ -503,7 +502,6 @@ const TitleLibrary: React.FC = () => {
|
||||
</h4>
|
||||
<span>{cat.count} 条</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -614,8 +612,6 @@ const TitleLibrary: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* ─── 新建标题弹窗 ─── */}
|
||||
<AntModal
|
||||
title="新建标题"
|
||||
|
||||
@@ -16,15 +16,15 @@ import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_has_audio, run_ffmpeg
|
||||
|
||||
# 延迟导入避免循环依赖:unified_render_service 定义 ResolvedClip / RenderLayer,
|
||||
# 本模块提供音频函数供 unified_render_service 调用。
|
||||
# 使用 from __future__ import annotations + TYPE_CHECKING 解决类型引用。
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_has_audio, run_ffmpeg
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from video_processing.unified_render_service import ResolvedClip, RenderLayer
|
||||
from video_processing.unified_render_service import RenderLayer, ResolvedClip
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -238,7 +238,10 @@ class UnifiedRenderService:
|
||||
else:
|
||||
# 回退到带滤镜的直通渲染
|
||||
pass_through_has_audio = self._render_pass_through(
|
||||
layers, output_path, ass_path=ass_path, video_duration=video_duration
|
||||
layers,
|
||||
output_path,
|
||||
ass_path=ass_path,
|
||||
video_duration=video_duration,
|
||||
)
|
||||
else:
|
||||
filter_complex, input_args = self._build_filter_complex(layers, ass_path=ass_path)
|
||||
@@ -981,4 +984,3 @@ class UnifiedRenderService:
|
||||
if clip.duration > 0:
|
||||
return min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration
|
||||
return clip.actual_duration if clip.actual_duration > 0 else 0.0
|
||||
|
||||
|
||||
@@ -67,7 +67,11 @@ def _update_task_status(task_id: str, status_action: str, **kwargs) -> bool:
|
||||
|
||||
action(**kwargs)
|
||||
repo.update(task)
|
||||
logger.info("GenerationTask 状态更新成功: task_id=%s action=%s", task_id, status_action)
|
||||
logger.info(
|
||||
"GenerationTask 状态更新成功: task_id=%s action=%s",
|
||||
task_id,
|
||||
status_action,
|
||||
)
|
||||
return True
|
||||
finally:
|
||||
session.close()
|
||||
@@ -458,7 +462,10 @@ def _download_library_assets(
|
||||
if not storage_key:
|
||||
failed_assets.append(f"{asset.name}({asset.id})")
|
||||
logger.warning(
|
||||
"[task_id=%s] 素材缺少 file_url, 跳过: asset_id=%s name=%s", task_id, asset.id, asset.name
|
||||
"[task_id=%s] 素材缺少 file_url, 跳过: asset_id=%s name=%s",
|
||||
task_id,
|
||||
asset.id,
|
||||
asset.name,
|
||||
)
|
||||
if gen_task:
|
||||
gen_task.append_log(
|
||||
@@ -504,7 +511,12 @@ def _download_library_assets(
|
||||
)
|
||||
else:
|
||||
failed_assets.append(f"{asset.name}({asset.id})")
|
||||
logger.warning("[task_id=%s] Failed to download asset: %s (id=%s)", task_id, asset.name, asset.id)
|
||||
logger.warning(
|
||||
"[task_id=%s] Failed to download asset: %s (id=%s)",
|
||||
task_id,
|
||||
asset.name,
|
||||
asset.id,
|
||||
)
|
||||
if gen_task:
|
||||
gen_task.append_log(
|
||||
"下载素材",
|
||||
@@ -910,10 +922,12 @@ def _upload_and_record(
|
||||
key = normalize_storage_key(file_url)
|
||||
if not (bucket and bucket.object_exists(key)):
|
||||
raise RuntimeError(
|
||||
f"OSS 上传后 URL 不可访问且 object_exists 失败: file_url={file_url}, "
|
||||
f"storage_key={storage_key}"
|
||||
f"OSS 上传后 URL 不可访问且 object_exists 失败: file_url={file_url}, " f"storage_key={storage_key}"
|
||||
)
|
||||
logger.info("URL 校验失败但 object_exists 确认文件存在,视为上传成功: storage_key=%s", key)
|
||||
logger.info(
|
||||
"URL 校验失败但 object_exists 确认文件存在,视为上传成功: storage_key=%s",
|
||||
key,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[task_id=%s] [OSS上传] 成功: 耗时=%.1fs, file_url=%s",
|
||||
|
||||
@@ -44,11 +44,61 @@ class InMemoryAssetRepository:
|
||||
return False
|
||||
|
||||
def batch_delete(self, asset_ids: list[str]) -> int:
|
||||
"""批量删除素材,返回实际删除数量。"""
|
||||
"""批量删除素材(软删除,标记 status=deleted),返回实际影响数量。"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from packages.domain import AssetStatus
|
||||
|
||||
count = 0
|
||||
for aid in asset_ids:
|
||||
if aid in self._assets:
|
||||
del self._assets[aid]
|
||||
asset = self._assets.get(aid)
|
||||
if asset and asset.status != AssetStatus.DELETED:
|
||||
asset.status = AssetStatus.DELETED
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def batch_update_metadata(self, asset_ids: list[str], metadata_patch: dict[str, object]) -> int:
|
||||
"""批量更新素材 metadata(合并 patch),返回实际影响数量。"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
count = 0
|
||||
for aid in asset_ids:
|
||||
asset = self._assets.get(aid)
|
||||
if asset:
|
||||
asset.metadata = {**asset.metadata, **metadata_patch}
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def batch_add_tags(self, asset_ids: list[str], tag_ids: list[str]) -> int:
|
||||
"""批量给素材添加标签(合并去重),返回实际影响数量。"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
count = 0
|
||||
for aid in asset_ids:
|
||||
asset = self._assets.get(aid)
|
||||
if asset:
|
||||
changed = False
|
||||
for tid in tag_ids:
|
||||
if tid not in asset.tag_ids:
|
||||
asset.tag_ids.append(tid)
|
||||
changed = True
|
||||
if changed:
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def batch_replace_tags(self, asset_ids: list[str], tag_ids: list[str]) -> int:
|
||||
"""批量替换素材标签(全量覆盖),返回实际影响数量。"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
count = 0
|
||||
for aid in asset_ids:
|
||||
asset = self._assets.get(aid)
|
||||
if asset:
|
||||
asset.tag_ids = list(tag_ids)
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
Regular → Executable
+82
-2
@@ -127,10 +127,90 @@ class SQLAlchemyAssetRepository:
|
||||
return False
|
||||
|
||||
def batch_delete(self, asset_ids: list[str]) -> int:
|
||||
"""批量删除素材,返回实际删除数量。"""
|
||||
"""批量删除素材(软删除,标记 status=deleted),返回实际影响数量。"""
|
||||
if not asset_ids:
|
||||
return 0
|
||||
count = self.session.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).delete(synchronize_session=False)
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
count = (
|
||||
self.session.query(AssetModel)
|
||||
.filter(AssetModel.id.in_(asset_ids), AssetModel.status != "deleted")
|
||||
.update({AssetModel.status: "deleted", AssetModel.updated_at: now}, synchronize_session=False)
|
||||
)
|
||||
self.session.commit()
|
||||
return count
|
||||
|
||||
def batch_update_metadata(self, asset_ids: list[str], metadata_patch: dict[str, object]) -> int:
|
||||
"""批量更新素材 metadata(合并 patch),返回实际影响数量。"""
|
||||
if not asset_ids:
|
||||
return 0
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
# 逐条读取 + 合并 + 更新,保证 JSON 合并正确
|
||||
models = self.session.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all()
|
||||
count = 0
|
||||
for model in models:
|
||||
existing = {}
|
||||
if model.classification_result:
|
||||
try:
|
||||
existing = json.loads(model.classification_result)
|
||||
except Exception:
|
||||
existing = {}
|
||||
merged = {**existing, **metadata_patch}
|
||||
model.classification_result = json.dumps(merged, ensure_ascii=False)
|
||||
model.updated_at = now
|
||||
count += 1
|
||||
self.session.commit()
|
||||
return count
|
||||
|
||||
def batch_add_tags(self, asset_ids: list[str], tag_ids: list[str]) -> int:
|
||||
"""批量给素材添加标签(合并去重),返回实际影响数量。"""
|
||||
if not asset_ids or not tag_ids:
|
||||
return 0
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
clean_tag_ids = list(set(tag_ids))
|
||||
count = 0
|
||||
for aid in asset_ids:
|
||||
# 查询现有标签
|
||||
existing = {
|
||||
row.tag_id
|
||||
for row in self.session.query(AssetTagModel.tag_id).filter(AssetTagModel.asset_id == aid).all()
|
||||
}
|
||||
new_tags = [t for t in clean_tag_ids if t not in existing]
|
||||
if new_tags:
|
||||
for tid in new_tags:
|
||||
self.session.add(AssetTagModel(asset_id=aid, tag_id=tid))
|
||||
# 更新 updated_at
|
||||
self.session.query(AssetModel).filter(AssetModel.id == aid).update(
|
||||
{AssetModel.updated_at: now}, synchronize_session=False
|
||||
)
|
||||
count += 1
|
||||
self.session.commit()
|
||||
return count
|
||||
|
||||
def batch_replace_tags(self, asset_ids: list[str], tag_ids: list[str]) -> int:
|
||||
"""批量替换素材标签(全量覆盖),返回实际影响数量。"""
|
||||
if not asset_ids:
|
||||
return 0
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
clean_tag_ids = list(set(tag_ids))
|
||||
count = 0
|
||||
for aid in asset_ids:
|
||||
# 先删再加
|
||||
self.session.query(AssetTagModel).filter(AssetTagModel.asset_id == aid).delete(synchronize_session=False)
|
||||
for tid in clean_tag_ids:
|
||||
self.session.add(AssetTagModel(asset_id=aid, tag_id=tid))
|
||||
# 更新 updated_at
|
||||
self.session.query(AssetModel).filter(AssetModel.id == aid).update(
|
||||
{AssetModel.updated_at: now}, synchronize_session=False
|
||||
)
|
||||
count += 1
|
||||
self.session.commit()
|
||||
return count
|
||||
|
||||
|
||||
@@ -133,6 +133,7 @@ class AssetStatus(StrEnum):
|
||||
READY = "ready"
|
||||
PROCESSING = "processing"
|
||||
ERROR = "error"
|
||||
DELETED = "deleted"
|
||||
|
||||
@classmethod
|
||||
def _missing_(cls, value: object) -> "AssetStatus":
|
||||
|
||||
Regular → Executable
+16
-1
@@ -52,7 +52,22 @@ class AssetRepository(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def batch_delete(self, asset_ids: list[str]) -> int:
|
||||
"""批量删除素材,返回实际删除数量。"""
|
||||
"""批量删除素材(软删除,标记 status=deleted),返回实际影响数量。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def batch_update_metadata(self, asset_ids: list[str], metadata_patch: dict[str, object]) -> int:
|
||||
"""批量更新素材 metadata(合并 patch),返回实际影响数量。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def batch_add_tags(self, asset_ids: list[str], tag_ids: list[str]) -> int:
|
||||
"""批量给素材添加标签(合并去重),返回实际影响数量。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def batch_replace_tags(self, asset_ids: list[str], tag_ids: list[str]) -> int:
|
||||
"""批量替换素材标签(全量覆盖),返回实际影响数量。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -63,8 +63,7 @@ BRANCH_NAME="${GITHUB_REF_NAME:-${CI_COMMIT_BRANCH:-unknown}}"
|
||||
if [ "$USE_CACHE" -eq 1 ]; then
|
||||
docker buildx build \
|
||||
--build-arg APP_VERSION="$VERSION" \
|
||||
--cache-from "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_TAG},ignore-error=true" \
|
||||
--cache-to "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_TAG},mode=max" \
|
||||
--cache-from "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_TAG_PRIMARY},ignore-error=true" \
|
||||
-f infra/docker/api.Dockerfile \
|
||||
-t "$API_IMAGE" -t "$API_LATEST" \
|
||||
--load \
|
||||
@@ -123,8 +122,7 @@ echo "=== Building Worker image ==="
|
||||
if [ "$USE_CACHE" -eq 1 ]; then
|
||||
docker buildx build \
|
||||
--build-arg APP_VERSION="$VERSION" \
|
||||
--cache-from "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_TAG},ignore-error=true" \
|
||||
--cache-to "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_TAG},mode=max" \
|
||||
--cache-from "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_TAG_PRIMARY},ignore-error=true" \
|
||||
-f infra/docker/worker.Dockerfile \
|
||||
-t "$WORKER_IMAGE" -t "$WORKER_LATEST" \
|
||||
--load \
|
||||
@@ -152,8 +150,7 @@ test -f apps/web/dist/index.html
|
||||
|
||||
if [ "$USE_CACHE" -eq 1 ]; then
|
||||
docker buildx build \
|
||||
--cache-from "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG},ignore-error=true" \
|
||||
--cache-to "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG},mode=max" \
|
||||
--cache-from "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG_PRIMARY},ignore-error=true" \
|
||||
-f infra/docker/web-artifact.Dockerfile \
|
||||
--build-arg "NGINX_CONF=$NGINX_CONF_FILE" \
|
||||
-t "$WEB_IMAGE" \
|
||||
|
||||
@@ -48,10 +48,19 @@ HIGH_RISK_PATTERNS = [
|
||||
|
||||
# 中风险模式:可能导致数据丢失或兼容性问题
|
||||
MEDIUM_RISK_PATTERNS = [
|
||||
(r"op\.alter_column\([^)]*nullable\s*=\s*False", "新增 NOT NULL 约束 - 旧数据可能为空导致迁移失败"),
|
||||
(
|
||||
r"op\.alter_column\([^)]*nullable\s*=\s*False",
|
||||
"新增 NOT NULL 约束 - 旧数据可能为空导致迁移失败",
|
||||
),
|
||||
(r"op\.alter_column\([^)]*type_\s*=", "列类型变更 - 可能导致数据截断或转换失败"),
|
||||
(r"\bop\.rename_table\(", "op.rename_table() - 重命名表,可能导致依赖该表的代码报错"),
|
||||
(r"\bop\.rename_column\(", "op.rename_column() - 重命名列,可能导致依赖该列的代码报错"),
|
||||
(
|
||||
r"\bop\.rename_table\(",
|
||||
"op.rename_table() - 重命名表,可能导致依赖该表的代码报错",
|
||||
),
|
||||
(
|
||||
r"\bop\.rename_column\(",
|
||||
"op.rename_column() - 重命名列,可能导致依赖该列的代码报错",
|
||||
),
|
||||
(r"\bop\.drop_index\(", "op.drop_index() - 删除索引,可能影响查询性能"),
|
||||
(r"\bop\.drop_constraint\(", "op.drop_constraint() - 删除约束,可能影响数据完整性"),
|
||||
]
|
||||
@@ -95,7 +104,16 @@ def get_new_migrations_via_diff(diff_target: str) -> List[Path]:
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", "--diff-filter=A", diff_target, "HEAD", "--", "alembic/versions/"],
|
||||
[
|
||||
"git",
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--diff-filter=A",
|
||||
diff_target,
|
||||
"HEAD",
|
||||
"--",
|
||||
"alembic/versions/",
|
||||
],
|
||||
cwd=str(REPO_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -247,4 +265,3 @@ def main() -> int:
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
|
||||
Regular → Executable
+63
-8
@@ -129,10 +129,58 @@ class StubAssetRepository:
|
||||
return False
|
||||
|
||||
def batch_delete(self, asset_ids: list[str]) -> int:
|
||||
"""软删除:标记 status=deleted。"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from packages.domain import AssetStatus
|
||||
|
||||
count = 0
|
||||
for aid in asset_ids:
|
||||
if aid in self._assets:
|
||||
del self._assets[aid]
|
||||
asset = self._assets.get(aid)
|
||||
if asset and asset.status != AssetStatus.DELETED:
|
||||
asset.status = AssetStatus.DELETED
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def batch_update_metadata(self, asset_ids: list[str], metadata_patch: dict) -> int:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
count = 0
|
||||
for aid in asset_ids:
|
||||
asset = self._assets.get(aid)
|
||||
if asset:
|
||||
asset.metadata = {**asset.metadata, **metadata_patch}
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def batch_add_tags(self, asset_ids: list[str], tag_ids: list[str]) -> int:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
count = 0
|
||||
for aid in asset_ids:
|
||||
asset = self._assets.get(aid)
|
||||
if asset:
|
||||
changed = False
|
||||
for tid in tag_ids:
|
||||
if tid not in asset.tag_ids:
|
||||
asset.tag_ids.append(tid)
|
||||
changed = True
|
||||
if changed:
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def batch_replace_tags(self, asset_ids: list[str], tag_ids: list[str]) -> int:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
count = 0
|
||||
for aid in asset_ids:
|
||||
asset = self._assets.get(aid)
|
||||
if asset:
|
||||
asset.tag_ids = list(tag_ids)
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
@@ -613,17 +661,22 @@ class TestBatchDeleteAssets:
|
||||
return ids
|
||||
|
||||
def test_batch_delete_success(self, client):
|
||||
"""批量删除成功。"""
|
||||
"""批量删除成功(软删除)。"""
|
||||
ids = self._create_assets(client, 3)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/assets/batch-delete",
|
||||
json={"ids": ids[:2]},
|
||||
json={"asset_ids": ids[:2]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["deleted_count"] == 2
|
||||
assert data["success_count"] == 2
|
||||
assert len(data["failed_ids"]) == 0
|
||||
# 软删除:记录仍在,status 变为 deleted
|
||||
for aid in ids[:2]:
|
||||
r = client.get(f"/api/v1/assets/{aid}")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "deleted"
|
||||
|
||||
def test_batch_delete_with_nonexistent_ids(self, client):
|
||||
"""批量删除包含不存在的 ID,失败的计入 failed_ids。"""
|
||||
@@ -632,18 +685,20 @@ class TestBatchDeleteAssets:
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/assets/batch-delete",
|
||||
json={"ids": ids},
|
||||
json={"asset_ids": ids},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["deleted_count"] == 2
|
||||
assert data["success_count"] == 2
|
||||
assert "nonexistent-id" in data["failed_ids"]
|
||||
assert "nonexistent-id" in data["failed_details"]
|
||||
assert data["failed_details"]["nonexistent-id"] == "not_found"
|
||||
|
||||
def test_batch_delete_empty_list_returns_422(self, client):
|
||||
"""空列表返回 422。"""
|
||||
resp = client.post(
|
||||
"/api/v1/assets/batch-delete",
|
||||
json={"ids": []},
|
||||
json={"asset_ids": []},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@@ -308,7 +308,7 @@ class TestPasswordReset:
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/auth/password/forgot",
|
||||
"/api/v1/auth/forgot-password",
|
||||
json={"email": test_email},
|
||||
)
|
||||
|
||||
@@ -318,7 +318,7 @@ class TestPasswordReset:
|
||||
def test_request_password_reset_nonexistent_user(self):
|
||||
"""测试请求不存在的用户密码重置"""
|
||||
response = client.post(
|
||||
"/api/v1/auth/password/forgot",
|
||||
"/api/v1/auth/forgot-password",
|
||||
json={"email": "nonexistent@example.com"},
|
||||
)
|
||||
|
||||
|
||||
Regular → Executable
+34
-11
@@ -1,4 +1,7 @@
|
||||
"""批量删除素材 + 分页优化 单元测试。"""
|
||||
"""批量删除素材 + 分页优化 单元测试。
|
||||
|
||||
注意:batch_delete 现在是软删除(标记 status=deleted),不是硬删除。
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -10,7 +13,7 @@ from packages.domain import Asset, AssetStatus
|
||||
|
||||
|
||||
class TestBatchDelete:
|
||||
"""batch_delete 仓储方法测试。"""
|
||||
"""batch_delete 仓储方法测试(软删除)。"""
|
||||
|
||||
def _make_repo_with_assets(self):
|
||||
repo = InMemoryAssetRepository()
|
||||
@@ -26,7 +29,8 @@ class TestBatchDelete:
|
||||
repo.create(asset)
|
||||
return repo
|
||||
|
||||
def test_batch_delete_removes_multiple(self):
|
||||
def test_batch_delete_marks_deleted_status(self):
|
||||
"""软删除:status 变为 deleted,记录仍然存在。"""
|
||||
repo = InMemoryAssetRepository()
|
||||
assets = []
|
||||
for i in range(5):
|
||||
@@ -36,6 +40,7 @@ class TestBatchDelete:
|
||||
name=f"voice_{i}.mp3",
|
||||
storage_key=f"uploads/voice_{i}.mp3",
|
||||
mime_type="audio/mpeg",
|
||||
status=AssetStatus.READY,
|
||||
)
|
||||
repo.create(asset)
|
||||
assets.append(asset)
|
||||
@@ -44,13 +49,13 @@ class TestBatchDelete:
|
||||
deleted_count = repo.batch_delete(ids_to_delete)
|
||||
|
||||
assert deleted_count == 3
|
||||
# 验证确实被删了
|
||||
assert repo.get(assets[0].id) is None
|
||||
assert repo.get(assets[2].id) is None
|
||||
assert repo.get(assets[4].id) is None
|
||||
# 验证其他还在
|
||||
assert repo.get(assets[1].id) is not None
|
||||
assert repo.get(assets[3].id) is not None
|
||||
# 软删除:记录仍在,status 变为 deleted
|
||||
assert repo.get(assets[0].id).status == AssetStatus.DELETED
|
||||
assert repo.get(assets[2].id).status == AssetStatus.DELETED
|
||||
assert repo.get(assets[4].id).status == AssetStatus.DELETED
|
||||
# 未删除的保持 ready
|
||||
assert repo.get(assets[1].id).status == AssetStatus.READY
|
||||
assert repo.get(assets[3].id).status == AssetStatus.READY
|
||||
|
||||
def test_batch_delete_empty_list(self):
|
||||
repo = self._make_repo_with_assets()
|
||||
@@ -69,9 +74,27 @@ class TestBatchDelete:
|
||||
name="voice.mp3",
|
||||
storage_key="uploads/voice.mp3",
|
||||
mime_type="audio/mpeg",
|
||||
status=AssetStatus.READY,
|
||||
)
|
||||
repo.create(asset)
|
||||
|
||||
deleted = repo.batch_delete([asset.id, "nonexistent"])
|
||||
assert deleted == 1
|
||||
assert repo.get(asset.id) is None
|
||||
assert repo.get(asset.id).status == AssetStatus.DELETED
|
||||
|
||||
def test_batch_delete_idempotent(self):
|
||||
"""重复删除已删除的素材不重复计数。"""
|
||||
repo = InMemoryAssetRepository()
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name="voice.mp3",
|
||||
storage_key="uploads/voice.mp3",
|
||||
mime_type="audio/mpeg",
|
||||
status=AssetStatus.READY,
|
||||
)
|
||||
repo.create(asset)
|
||||
|
||||
assert repo.batch_delete([asset.id]) == 1
|
||||
assert repo.batch_delete([asset.id]) == 0
|
||||
assert repo.get(asset.id).status == AssetStatus.DELETED
|
||||
|
||||
Executable
+288
@@ -0,0 +1,288 @@
|
||||
"""素材批量操作单元测试:软删除、批量打标签、批量分类、批量智能视图标记。"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from packages.adapters.in_memory.asset_repository import InMemoryAssetRepository
|
||||
from packages.domain import Asset, AssetStatus
|
||||
|
||||
|
||||
class TestBatchSoftDelete:
|
||||
"""batch_delete 软删除测试。"""
|
||||
|
||||
def _make_assets(self, repo: InMemoryAssetRepository, count: int = 5) -> list[Asset]:
|
||||
assets = []
|
||||
for i in range(count):
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name=f"asset_{i}.mp4",
|
||||
storage_key=f"uploads/asset_{i}.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.READY,
|
||||
)
|
||||
repo.create(asset)
|
||||
assets.append(asset)
|
||||
return assets
|
||||
|
||||
def test_batch_soft_delete_marks_status_deleted(self):
|
||||
"""软删除:status 变为 deleted,记录仍然存在。"""
|
||||
repo = InMemoryAssetRepository()
|
||||
assets = self._make_assets(repo, 3)
|
||||
|
||||
ids_to_delete = [assets[0].id, assets[2].id]
|
||||
count = repo.batch_delete(ids_to_delete)
|
||||
|
||||
assert count == 2
|
||||
# 记录仍在,只是 status 变了
|
||||
assert repo.get(assets[0].id) is not None
|
||||
assert repo.get(assets[0].id).status == AssetStatus.DELETED
|
||||
assert repo.get(assets[2].id).status == AssetStatus.DELETED
|
||||
# 未删除的保持原样
|
||||
assert repo.get(assets[1].id).status == AssetStatus.READY
|
||||
|
||||
def test_batch_soft_delete_idempotent(self):
|
||||
"""重复删除已删除的素材,计数不增加。"""
|
||||
repo = InMemoryAssetRepository()
|
||||
assets = self._make_assets(repo, 2)
|
||||
|
||||
count1 = repo.batch_delete([assets[0].id])
|
||||
count2 = repo.batch_delete([assets[0].id])
|
||||
|
||||
assert count1 == 1
|
||||
assert count2 == 0
|
||||
assert repo.get(assets[0].id).status == AssetStatus.DELETED
|
||||
|
||||
def test_batch_soft_delete_empty_list(self):
|
||||
repo = InMemoryAssetRepository()
|
||||
self._make_assets(repo, 3)
|
||||
assert repo.batch_delete([]) == 0
|
||||
|
||||
def test_batch_soft_delete_nonexistent_ids(self):
|
||||
repo = InMemoryAssetRepository()
|
||||
self._make_assets(repo, 3)
|
||||
assert repo.batch_delete(["nonexistent-1", "nonexistent-2"]) == 0
|
||||
|
||||
|
||||
class TestBatchUpdateMetadata:
|
||||
"""batch_update_metadata 批量更新 metadata 测试。"""
|
||||
|
||||
def test_batch_update_category(self):
|
||||
"""批量修改分类(metadata.category)。"""
|
||||
repo = InMemoryAssetRepository()
|
||||
assets = []
|
||||
for i in range(3):
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name=f"v{i}.mp4",
|
||||
storage_key=f"v{i}.mp4",
|
||||
mime_type="video/mp4",
|
||||
metadata={"existing_key": "existing_value"},
|
||||
status=AssetStatus.READY,
|
||||
)
|
||||
repo.create(asset)
|
||||
assets.append(asset)
|
||||
|
||||
ids = [a.id for a in assets]
|
||||
count = repo.batch_update_metadata(ids, {"category": "person"})
|
||||
|
||||
assert count == 3
|
||||
for a in assets:
|
||||
updated = repo.get(a.id)
|
||||
assert updated.metadata["category"] == "person"
|
||||
assert updated.metadata["existing_key"] == "existing_value" # 合并而非覆盖
|
||||
|
||||
def test_batch_update_smart_view(self):
|
||||
"""批量设置智能视图标记。"""
|
||||
repo = InMemoryAssetRepository()
|
||||
assets = []
|
||||
for i in range(4):
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name=f"v{i}.mp4",
|
||||
storage_key=f"v{i}.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.READY,
|
||||
)
|
||||
repo.create(asset)
|
||||
assets.append(asset)
|
||||
|
||||
# 标记前2个为 recommended
|
||||
count = repo.batch_update_metadata([assets[0].id, assets[1].id], {"smart_view": "recommended"})
|
||||
assert count == 2
|
||||
assert repo.get(assets[0].id).metadata["smart_view"] == "recommended"
|
||||
assert repo.get(assets[1].id).metadata["smart_view"] == "recommended"
|
||||
# 其余不变
|
||||
assert "smart_view" not in repo.get(assets[2].id).metadata
|
||||
|
||||
# 再标记后2个为 high_risk
|
||||
count2 = repo.batch_update_metadata([assets[2].id, assets[3].id], {"smart_view": "high_risk"})
|
||||
assert count2 == 2
|
||||
assert repo.get(assets[2].id).metadata["smart_view"] == "high_risk"
|
||||
assert repo.get(assets[3].id).metadata["smart_view"] == "high_risk"
|
||||
|
||||
def test_batch_update_metadata_partial_existing(self):
|
||||
"""部分素材存在时,只更新存在的。"""
|
||||
repo = InMemoryAssetRepository()
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name="v.mp4",
|
||||
storage_key="v.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.READY,
|
||||
)
|
||||
repo.create(asset)
|
||||
|
||||
count = repo.batch_update_metadata([asset.id, "nonexistent"], {"category": "scenic"})
|
||||
assert count == 1
|
||||
assert repo.get(asset.id).metadata["category"] == "scenic"
|
||||
|
||||
def test_batch_update_metadata_empty_list(self):
|
||||
repo = InMemoryAssetRepository()
|
||||
assert repo.batch_update_metadata([], {"category": "x"}) == 0
|
||||
|
||||
|
||||
class TestBatchAddTags:
|
||||
"""batch_add_tags 批量添加标签测试。"""
|
||||
|
||||
def test_batch_add_tags_merges_and_dedups(self):
|
||||
"""添加模式:合并去重,已有标签不重复添加。"""
|
||||
repo = InMemoryAssetRepository()
|
||||
assets = []
|
||||
for i in range(3):
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name=f"v{i}.mp4",
|
||||
storage_key=f"v{i}.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.READY,
|
||||
)
|
||||
asset.add_tag("tag-existing")
|
||||
repo.create(asset)
|
||||
assets.append(asset)
|
||||
|
||||
ids = [a.id for a in assets]
|
||||
count = repo.batch_add_tags(ids, ["tag-1", "tag-2", "tag-existing"])
|
||||
|
||||
assert count == 3 # 都有新增标签,所以都算变更
|
||||
for a in assets:
|
||||
updated = repo.get(a.id)
|
||||
assert set(updated.tag_ids) == {"tag-existing", "tag-1", "tag-2"}
|
||||
|
||||
def test_batch_add_tags_no_change_when_all_exist(self):
|
||||
"""所有标签都已存在时,返回0。"""
|
||||
repo = InMemoryAssetRepository()
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name="v.mp4",
|
||||
storage_key="v.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.READY,
|
||||
)
|
||||
asset.add_tag("tag-a")
|
||||
asset.add_tag("tag-b")
|
||||
repo.create(asset)
|
||||
|
||||
count = repo.batch_add_tags([asset.id], ["tag-a", "tag-b"])
|
||||
assert count == 0
|
||||
|
||||
def test_batch_add_tags_empty_input(self):
|
||||
repo = InMemoryAssetRepository()
|
||||
assert repo.batch_add_tags([], ["tag-1"]) == 0
|
||||
assert repo.batch_add_tags(["aid"], []) == 0
|
||||
|
||||
|
||||
class TestBatchReplaceTags:
|
||||
"""batch_replace_tags 批量替换标签测试。"""
|
||||
|
||||
def test_batch_replace_tags_full_override(self):
|
||||
"""替换模式:全量覆盖原有标签。"""
|
||||
repo = InMemoryAssetRepository()
|
||||
assets = []
|
||||
for i in range(3):
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name=f"v{i}.mp4",
|
||||
storage_key=f"v{i}.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.READY,
|
||||
)
|
||||
asset.add_tag(f"old-{i}")
|
||||
asset.add_tag("old-common")
|
||||
repo.create(asset)
|
||||
assets.append(asset)
|
||||
|
||||
ids = [a.id for a in assets]
|
||||
count = repo.batch_replace_tags(ids, ["new-1", "new-2"])
|
||||
|
||||
assert count == 3
|
||||
for a in assets:
|
||||
updated = repo.get(a.id)
|
||||
assert set(updated.tag_ids) == {"new-1", "new-2"}
|
||||
|
||||
def test_batch_replace_tags_empty_tags_clears_all(self):
|
||||
"""替换为空列表:清空所有标签。"""
|
||||
repo = InMemoryAssetRepository()
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name="v.mp4",
|
||||
storage_key="v.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.READY,
|
||||
)
|
||||
asset.add_tag("tag-a")
|
||||
asset.add_tag("tag-b")
|
||||
repo.create(asset)
|
||||
|
||||
count = repo.batch_replace_tags([asset.id], [])
|
||||
assert count == 1
|
||||
assert repo.get(asset.id).tag_ids == []
|
||||
|
||||
def test_batch_replace_tags_empty_assets(self):
|
||||
repo = InMemoryAssetRepository()
|
||||
assert repo.batch_replace_tags([], ["tag-1"]) == 0
|
||||
|
||||
|
||||
class TestBatchOperationLimits:
|
||||
"""批量操作上限与边界测试。"""
|
||||
|
||||
def test_large_batch_operations(self):
|
||||
"""大量素材的批量操作(验证性能基本可用)。"""
|
||||
repo = InMemoryAssetRepository()
|
||||
assets = []
|
||||
for i in range(50):
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name=f"v{i}.mp4",
|
||||
storage_key=f"v{i}.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.READY,
|
||||
)
|
||||
repo.create(asset)
|
||||
assets.append(asset)
|
||||
|
||||
ids = [a.id for a in assets]
|
||||
|
||||
# 批量打标签
|
||||
count = repo.batch_add_tags(ids, ["bulk-tag"])
|
||||
assert count == 50
|
||||
|
||||
# 批量分类
|
||||
count = repo.batch_update_metadata(ids, {"category": "scenic"})
|
||||
assert count == 50
|
||||
|
||||
# 批量软删除
|
||||
count = repo.batch_delete(ids)
|
||||
assert count == 50
|
||||
for a in assets:
|
||||
assert repo.get(a.id).status == AssetStatus.DELETED
|
||||
@@ -219,7 +219,7 @@ class TestDeleteAssetLibrary:
|
||||
|
||||
response = client.delete("/api/v1/asset-libraries/lib-1")
|
||||
assert response.status_code == 403
|
||||
assert "Access denied" in response.json()["detail"]
|
||||
assert "无权访问该项目" in response.json()["detail"]
|
||||
|
||||
# 库未被删除
|
||||
assert lib_repo.find_by_id("lib-1") is not None
|
||||
|
||||
Regular → Executable
+1
-1
@@ -37,7 +37,7 @@ def _fresh_settings(**env_overrides: dict[str, str]):
|
||||
"JWT_SECRET_KEY": "unit-test-secret-key-12345",
|
||||
**env_overrides,
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
Settings = _load_settings_class()
|
||||
return Settings()
|
||||
|
||||
|
||||
Executable → Regular
+7
-7
@@ -318,7 +318,7 @@ class TestGeneratePlan:
|
||||
clip = _make_clip(plan.id, order=1)
|
||||
clip_repo.create(clip)
|
||||
|
||||
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
|
||||
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
|
||||
mock_celery.send_task = MagicMock()
|
||||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||||
|
||||
@@ -406,7 +406,7 @@ class TestGeneratePlan:
|
||||
clip = _make_clip(plan.id, order=1, status=EditPlanClipStatus.READY)
|
||||
clip_repo.create(clip)
|
||||
|
||||
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
|
||||
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
|
||||
mock_celery.send_task = MagicMock()
|
||||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||||
|
||||
@@ -427,7 +427,7 @@ class TestGeneratePlan:
|
||||
clip = _make_clip(plan.id, order=i + 1)
|
||||
clip_repo.create(clip)
|
||||
|
||||
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
|
||||
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
|
||||
mock_celery.send_task = MagicMock()
|
||||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||||
|
||||
@@ -580,7 +580,7 @@ class TestResponseSchema:
|
||||
clip = _make_clip(plan.id, order=1)
|
||||
clip_repo.create(clip)
|
||||
|
||||
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
|
||||
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
|
||||
mock_celery.send_task = MagicMock()
|
||||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||||
|
||||
@@ -623,7 +623,7 @@ class TestGeneratePlanErrorHandling:
|
||||
clip = _make_clip(plan.id, order=1)
|
||||
clip_repo.create(clip)
|
||||
|
||||
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
|
||||
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
|
||||
# 模拟 Celery 调度失败
|
||||
mock_celery.send_task.side_effect = RuntimeError("Redis 连接超时")
|
||||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||||
@@ -647,7 +647,7 @@ class TestGeneratePlanErrorHandling:
|
||||
clip = _make_clip(plan.id, order=1)
|
||||
clip_repo.create(clip)
|
||||
|
||||
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
|
||||
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
|
||||
mock_celery.send_task.side_effect = RuntimeError("调度失败")
|
||||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||||
|
||||
@@ -668,7 +668,7 @@ class TestGeneratePlanErrorHandling:
|
||||
clip = _make_clip(plan.id, order=1)
|
||||
clip_repo.create(clip)
|
||||
|
||||
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
|
||||
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
|
||||
mock_celery.send_task.side_effect = ConnectionError("Broker 不可达")
|
||||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||||
|
||||
|
||||
Executable → Regular
-1
@@ -7,7 +7,6 @@ from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
from packages.adapters.redis.feature_flag_store import (
|
||||
FeatureFlagConfig,
|
||||
InMemoryFeatureFlagStore,
|
||||
|
||||
@@ -8,9 +8,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
import app.config as app_config
|
||||
from app.core.storage import OSSStorageService
|
||||
|
||||
import packages.shared.config as shared_config
|
||||
|
||||
|
||||
def _reset_settings() -> None:
|
||||
app_config._settings = None
|
||||
shared_config._settings = None
|
||||
|
||||
|
||||
def test_create_direct_upload_post_limits_key_and_size(monkeypatch):
|
||||
|
||||
@@ -9,7 +9,6 @@ from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
from packages.application.cosyvoice_service import CosyVoiceService
|
||||
from packages.application.tts_job.workflow import TTSWorkflowService
|
||||
from packages.domain.tts_job import TTSJob, TTSJobStatus
|
||||
|
||||
Reference in New Issue
Block a user