b0f2e4712a
CI/CD Pipeline / Deploy Staging (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Failing after 48h55m59s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 48h55m59s
- 新增 Tag 领域实体 + TagModel/AssetTagModel ORM 模型
- Alembic 迁移 030:tags 表 + asset_tags 关联表
- TagRepository 端口 + SQLAlchemy/InMemory 实现
- Asset.tag_ids 多对多关联替代原 JSON tags
- GET /tags / POST /tags / DELETE /tags/{tag_id} 标签 CRUD
- POST /assets/{asset_id}/tags 打标 / DELETE 取消标签
- GET /assets 新增 tag_ids 筛选参数(逗号分隔,取交集)
- 19 个单元测试全部通过
436 lines
17 KiB
Python
436 lines
17 KiB
Python
import logging
|
||
from typing import Any, Optional
|
||
|
||
from app.auth import AuthenticatedUser, get_current_user
|
||
from app.core.storage import get_storage_service
|
||
from app.dependencies import (
|
||
get_asset_library_repository,
|
||
get_asset_repository,
|
||
get_project_repository,
|
||
get_tag_repository,
|
||
)
|
||
from app.schemas.asset import (
|
||
AssetResponse,
|
||
BatchDeleteRequest,
|
||
BatchDeleteResponse,
|
||
CreateAssetRequest,
|
||
ListAssetsResponse,
|
||
UpdateAssetRequest,
|
||
UpdateAssetReviewRequest,
|
||
)
|
||
from app.schemas.tag import TagAssetsRequest
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
|
||
from packages.application import (
|
||
CreateAssetCommand,
|
||
CreateAssetUseCase,
|
||
)
|
||
from packages.domain import AssetStatus, ClassificationStatus
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
def _to_asset_response(item, storage_service=None) -> AssetResponse:
|
||
# 生成签名文件 URL(用于视频播放 / 文件下载)
|
||
file_url = None
|
||
if item.storage_key:
|
||
try:
|
||
svc = storage_service or get_storage_service()
|
||
file_url = svc.get_download_url(item.storage_key)
|
||
except Exception:
|
||
logger.warning("生成签名URL失败: storage_key=%s", item.storage_key, exc_info=True)
|
||
file_url = None
|
||
|
||
# 缩略图:优先用已有 thumbnail_url,否则对视频素材复用文件签名 URL
|
||
thumbnail_url = item.thumbnail_url
|
||
if not thumbnail_url and item.mime_type and item.mime_type.startswith("video") and file_url:
|
||
thumbnail_url = file_url
|
||
|
||
return AssetResponse(
|
||
id=item.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,
|
||
file_url=file_url,
|
||
thumbnail_url=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,
|
||
tag_ids=getattr(item, "tag_ids", []),
|
||
)
|
||
|
||
|
||
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
|
||
"""检查用户是否有项目访问权限"""
|
||
project = project_repository.find_by_id(project_id)
|
||
if project is None:
|
||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||
if not project.can_access(user_id):
|
||
raise HTTPException(status_code=403, detail="Access denied to project")
|
||
|
||
|
||
@router.get("", response_model=ListAssetsResponse)
|
||
def list_assets(
|
||
library_id: Optional[str] = Query(None),
|
||
project_id: Optional[str] = Query(None),
|
||
kind: Optional[str] = Query(None, pattern="^(video|voice|image)$"),
|
||
keyword: Optional[str] = Query(None, description="按名称模糊匹配"),
|
||
gender: Optional[str] = Query(None, description="按 metadata.gender 筛选"),
|
||
style: Optional[str] = Query(None, description="按 metadata.style 筛选"),
|
||
tag_ids: Optional[str] = Query(None, description="按标签 ID 筛选(逗号分隔,取交集)"),
|
||
skip: int = Query(0, ge=0),
|
||
limit: int = Query(100, ge=1, le=500),
|
||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||
asset_repository: Any = Depends(get_asset_repository),
|
||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||
project_repository: Any = Depends(get_project_repository),
|
||
) -> ListAssetsResponse:
|
||
user_id = authenticated_user.user.id
|
||
|
||
# kind → file_type 映射(voice 对应 audio)
|
||
kind_to_file_type = {"video": "video", "voice": "audio", "image": "image"}
|
||
|
||
# 解析 tag_ids 参数(逗号分隔)
|
||
filter_tag_ids: list[str] | None = None
|
||
if tag_ids:
|
||
filter_tag_ids = [t.strip() for t in tag_ids.split(",") if t.strip()]
|
||
if not filter_tag_ids:
|
||
filter_tag_ids = None
|
||
|
||
# 需要内存过滤的标志(keyword/gender/style/tag_ids 无法在 DB 层过滤)
|
||
needs_memory_filter = bool(keyword or gender or style or filter_tag_ids)
|
||
|
||
def _apply_memory_filters(items):
|
||
"""应用 keyword / gender / style / tag_ids 内存过滤。"""
|
||
result = items
|
||
if keyword:
|
||
kw = keyword.lower()
|
||
result = [i for i in result if kw in (i.name or "").lower()]
|
||
if gender:
|
||
result = [i for i in result if (i.metadata or {}).get("gender") == gender]
|
||
if style:
|
||
result = [i for i in result if (i.metadata or {}).get("style") == style]
|
||
if filter_tag_ids:
|
||
tag_set = set(filter_tag_ids)
|
||
result = [i for i in result if tag_set.issubset(set(getattr(i, "tag_ids", [])))]
|
||
return result
|
||
|
||
# ── 优化路径:无内存过滤时,使用 DB 级分页 ──
|
||
if not needs_memory_filter:
|
||
ft = kind_to_file_type.get(kind) if kind else None
|
||
|
||
# 模式1:指定 library_id
|
||
if library_id:
|
||
library = asset_library_repository.get(library_id)
|
||
if library is None:
|
||
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
|
||
_check_project_access(library.project_id, user_id, project_repository)
|
||
if ft:
|
||
items = asset_repository.find_by_library_and_file_type(library_id, ft, skip=skip, limit=limit)
|
||
total = asset_repository.count_by_project(library.project_id) if not kind else len(items)
|
||
else:
|
||
items = asset_repository.find_by_library(library_id, skip=skip, limit=limit)
|
||
total = asset_repository.count_by_project(library.project_id)
|
||
return ListAssetsResponse(
|
||
items=[_to_asset_response(item) for item in items],
|
||
total=total,
|
||
skip=skip,
|
||
limit=limit,
|
||
)
|
||
|
||
# 模式2:指定 project_id
|
||
if project_id:
|
||
_check_project_access(project_id, user_id, project_repository)
|
||
if ft:
|
||
# 无直接方法,加载后按 file_type 过滤(仍比全量加载好)
|
||
all_items = asset_repository.find_by_project(project_id)
|
||
items = [i for i in all_items if i.mime_type and i.mime_type.startswith(ft)]
|
||
total = len(items)
|
||
paged = items[skip : skip + limit]
|
||
else:
|
||
items = asset_repository.find_by_project(project_id, skip=skip, limit=limit)
|
||
total = asset_repository.count_by_project(project_id)
|
||
paged = items
|
||
return ListAssetsResponse(
|
||
items=[_to_asset_response(item) for item in paged],
|
||
total=total,
|
||
skip=skip,
|
||
limit=limit,
|
||
)
|
||
|
||
# 模式3:跨项目(无 library_id/project_id)
|
||
try:
|
||
projects = project_repository.find_accessible_projects(user_id)
|
||
except Exception:
|
||
logger.exception("查询用户可访问项目失败: user_id=%s", user_id)
|
||
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
|
||
|
||
project_ids = [p.id for p in projects]
|
||
if not project_ids:
|
||
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
|
||
|
||
total = asset_repository.count_by_project_ids(project_ids)
|
||
# 跨项目分页:逐项目累积直到凑够一页
|
||
paged_items: list = []
|
||
offset = skip
|
||
remaining = limit
|
||
for pid in project_ids:
|
||
proj_total = asset_repository.count_by_project(pid)
|
||
if offset >= proj_total:
|
||
offset -= proj_total
|
||
continue
|
||
proj_items = asset_repository.find_by_project(pid, skip=offset, limit=remaining)
|
||
paged_items.extend(proj_items)
|
||
remaining -= len(proj_items)
|
||
offset = 0
|
||
if remaining <= 0:
|
||
break
|
||
|
||
return ListAssetsResponse(
|
||
items=[_to_asset_response(item) for item in paged_items],
|
||
total=total,
|
||
skip=skip,
|
||
limit=limit,
|
||
)
|
||
|
||
# ── 内存过滤路径:有 keyword/gender/style 时,加载全量后内存过滤 ──
|
||
if library_id:
|
||
library = asset_library_repository.get(library_id)
|
||
if library is None:
|
||
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
|
||
_check_project_access(library.project_id, user_id, project_repository)
|
||
if kind:
|
||
all_items = asset_repository.find_by_library_and_file_type(library_id, kind_to_file_type[kind])
|
||
else:
|
||
all_items = asset_repository.find_by_library(library_id)
|
||
elif project_id:
|
||
_check_project_access(project_id, user_id, project_repository)
|
||
all_items = asset_repository.find_by_project(project_id)
|
||
else:
|
||
try:
|
||
projects = project_repository.find_accessible_projects(user_id)
|
||
except Exception:
|
||
logger.exception("查询用户可访问项目失败: user_id=%s", user_id)
|
||
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
|
||
all_items = []
|
||
for proj in projects:
|
||
all_items.extend(asset_repository.find_by_project(proj.id))
|
||
|
||
# 应用 kind 过滤(如果有)+ keyword/gender/style
|
||
if kind:
|
||
ft = kind_to_file_type.get(kind)
|
||
all_items = [i for i in all_items if i.mime_type and i.mime_type.startswith(ft or "")]
|
||
filtered = _apply_memory_filters(all_items)
|
||
total = len(filtered)
|
||
paged = filtered[skip : skip + limit]
|
||
return ListAssetsResponse(
|
||
items=[_to_asset_response(item) for item in paged],
|
||
total=total,
|
||
skip=skip,
|
||
limit=limit,
|
||
)
|
||
|
||
|
||
def _apply_asset_review_status(item, review_status: str):
|
||
item.metadata = {
|
||
**item.metadata,
|
||
"review_status": review_status,
|
||
}
|
||
return item
|
||
|
||
|
||
@router.patch("/{asset_id}/review", response_model=AssetResponse)
|
||
def update_asset_review_status(
|
||
asset_id: str,
|
||
request: UpdateAssetReviewRequest,
|
||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||
asset_repository: Any = Depends(get_asset_repository),
|
||
project_repository: Any = Depends(get_project_repository),
|
||
) -> AssetResponse:
|
||
item = asset_repository.get(asset_id)
|
||
if item is None:
|
||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||
_apply_asset_review_status(item, request.review_status)
|
||
updated = asset_repository.update(item)
|
||
return _to_asset_response(updated)
|
||
|
||
|
||
@router.post("/batch-delete", response_model=BatchDeleteResponse)
|
||
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:
|
||
"""批量删除素材(配音素材等),需逐项校验项目权限。"""
|
||
user_id = authenticated_user.user.id
|
||
deleted_ids: list[str] = []
|
||
failed_ids: list[str] = []
|
||
|
||
for asset_id in request.ids:
|
||
item = asset_repository.find_by_id(asset_id)
|
||
if item is None:
|
||
failed_ids.append(asset_id)
|
||
continue
|
||
try:
|
||
_check_project_access(item.project_id, user_id, project_repository)
|
||
deleted_ids.append(asset_id)
|
||
except HTTPException:
|
||
failed_ids.append(asset_id)
|
||
|
||
if deleted_ids:
|
||
asset_repository.batch_delete(deleted_ids)
|
||
|
||
return BatchDeleteResponse(deleted_count=len(deleted_ids), failed_ids=failed_ids)
|
||
|
||
|
||
@router.get("/{asset_id}", response_model=AssetResponse)
|
||
def get_asset(
|
||
asset_id: str,
|
||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||
asset_repository: Any = Depends(get_asset_repository),
|
||
project_repository: Any = Depends(get_project_repository),
|
||
) -> AssetResponse:
|
||
item = asset_repository.find_by_id(asset_id)
|
||
if item is None:
|
||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||
return _to_asset_response(item)
|
||
|
||
|
||
@router.put("/{asset_id}", response_model=AssetResponse)
|
||
def update_asset(
|
||
asset_id: str,
|
||
request: UpdateAssetRequest,
|
||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||
asset_repository: Any = Depends(get_asset_repository),
|
||
project_repository: Any = Depends(get_project_repository),
|
||
) -> AssetResponse:
|
||
item = asset_repository.find_by_id(asset_id)
|
||
if item is None:
|
||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||
|
||
# 合并可修改字段
|
||
if request.name is not None:
|
||
item.name = request.name
|
||
if request.metadata is not None:
|
||
item.metadata = {**item.metadata, **request.metadata}
|
||
if request.tags is not None:
|
||
item.metadata = {**item.metadata, "tags": request.tags}
|
||
|
||
updated = asset_repository.update(item)
|
||
return _to_asset_response(updated)
|
||
|
||
|
||
@router.delete("/{asset_id}", status_code=204)
|
||
def delete_asset(
|
||
asset_id: str,
|
||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||
asset_repository: Any = Depends(get_asset_repository),
|
||
project_repository: Any = Depends(get_project_repository),
|
||
) -> None:
|
||
item = asset_repository.find_by_id(asset_id)
|
||
if item is None:
|
||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||
asset_repository.delete(asset_id)
|
||
|
||
|
||
@router.post("/{asset_id}/tags", response_model=AssetResponse)
|
||
def tag_asset(
|
||
asset_id: str,
|
||
request: TagAssetsRequest,
|
||
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),
|
||
) -> AssetResponse:
|
||
"""给素材打标签。"""
|
||
item = asset_repository.find_by_id(asset_id)
|
||
if item is None:
|
||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||
for tag_id in request.tag_ids:
|
||
tag = tag_repository.get(tag_id)
|
||
if tag is None:
|
||
raise HTTPException(status_code=404, detail=f"Tag {tag_id} not found")
|
||
if tag.user_id != authenticated_user.user.id:
|
||
raise HTTPException(status_code=403, detail=f"无权使用标签 {tag_id}")
|
||
item.add_tag(tag_id)
|
||
updated = asset_repository.update(item)
|
||
return _to_asset_response(updated)
|
||
|
||
|
||
@router.delete("/{asset_id}/tags/{tag_id}", status_code=204)
|
||
def untag_asset(
|
||
asset_id: str,
|
||
tag_id: str,
|
||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||
asset_repository: Any = Depends(get_asset_repository),
|
||
project_repository: Any = Depends(get_project_repository),
|
||
) -> None:
|
||
"""取消素材的标签。"""
|
||
item = asset_repository.find_by_id(asset_id)
|
||
if item is None:
|
||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||
item.remove_tag(tag_id)
|
||
asset_repository.update(item)
|
||
|
||
|
||
@router.post("", response_model=AssetResponse)
|
||
def create_asset(
|
||
request: CreateAssetRequest,
|
||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||
asset_repository: Any = Depends(get_asset_repository),
|
||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||
project_repository: Any = Depends(get_project_repository),
|
||
) -> AssetResponse:
|
||
project = project_repository.find_by_id(request.project_id)
|
||
if project is None:
|
||
raise HTTPException(status_code=404, detail=f"Project {request.project_id} not found")
|
||
if not project.can_access(authenticated_user.user.id):
|
||
raise HTTPException(status_code=403, detail="Access denied to project")
|
||
|
||
library = asset_library_repository.get(request.library_id)
|
||
if library is None or library.project_id != request.project_id:
|
||
raise HTTPException(status_code=404, detail=f"AssetLibrary {request.library_id} not found")
|
||
|
||
use_case = CreateAssetUseCase(asset_repository)
|
||
item = use_case.execute(
|
||
CreateAssetCommand(
|
||
project_id=request.project_id,
|
||
library_id=request.library_id,
|
||
name=request.name,
|
||
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=authenticated_user.user.id,
|
||
)
|
||
)
|
||
return _to_asset_response(item)
|