feat: P3 配音模块标签体系 — 标签 CRUD + 素材打标 + tag_ids 筛选
CI/CD Pipeline / Frontend Lint (push) Failing after 48h55m59s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 48h55m59s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1707h31m26s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1707h31m28s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1707h31m30s
CI/CD Pipeline / Deploy Staging (push) Failing after 1707h31m30s
CI/CD Pipeline / Deploy Production (push) Failing after 1708h2m56s
CI/CD Pipeline / Frontend Lint (push) Failing after 48h55m59s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 48h55m59s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1707h31m26s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1707h31m28s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1707h31m30s
CI/CD Pipeline / Deploy Staging (push) Failing after 1707h31m30s
CI/CD Pipeline / Deploy Production (push) Failing after 1708h2m56s
- 新增 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 个单元测试全部通过
This commit is contained in:
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel, AssetTagModel
|
||||
from packages.domain import Asset, AssetStatus, ClassificationStatus
|
||||
|
||||
|
||||
@@ -87,6 +87,8 @@ class SQLAlchemyAssetRepository:
|
||||
updated_at=now,
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.flush()
|
||||
self._sync_asset_tags(asset.id, asset.tag_ids)
|
||||
self.session.commit()
|
||||
return asset
|
||||
|
||||
@@ -109,6 +111,8 @@ class SQLAlchemyAssetRepository:
|
||||
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.flush()
|
||||
self._sync_asset_tags(asset.id, asset.tag_ids)
|
||||
self.session.commit()
|
||||
return asset
|
||||
|
||||
@@ -203,6 +207,11 @@ class SQLAlchemyAssetRepository:
|
||||
"audio": "audio/mpeg",
|
||||
"image": "image/jpeg",
|
||||
}.get(mime_type, mime_type)
|
||||
# 查询关联的 tag_ids
|
||||
tag_ids = [
|
||||
row.tag_id
|
||||
for row in self.session.query(AssetTagModel.tag_id).filter(AssetTagModel.asset_id == model.id).all()
|
||||
]
|
||||
return Asset(
|
||||
id=model.id,
|
||||
project_id=model.project_id,
|
||||
@@ -222,6 +231,39 @@ class SQLAlchemyAssetRepository:
|
||||
quality_score=model.quality_score,
|
||||
uploaded_by_user_id=model.uploaded_by_user_id,
|
||||
metadata=metadata,
|
||||
tag_ids=tag_ids,
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
|
||||
def _sync_asset_tags(self, asset_id: str, tag_ids: list[str]) -> None:
|
||||
"""同步素材-标签关联表(全量替换)。"""
|
||||
self.session.query(AssetTagModel).filter(AssetTagModel.asset_id == asset_id).delete(synchronize_session=False)
|
||||
for tag_id in tag_ids:
|
||||
self.session.add(AssetTagModel(asset_id=asset_id, tag_id=tag_id))
|
||||
|
||||
def find_by_tag_ids(
|
||||
self,
|
||||
tag_ids: list[str],
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> list[Asset]:
|
||||
"""查找包含所有指定标签的素材。"""
|
||||
if not tag_ids:
|
||||
return []
|
||||
from sqlalchemy import func
|
||||
|
||||
# 找出同时拥有所有指定 tag_id 的 asset_id
|
||||
tag_set = set(tag_ids)
|
||||
asset_ids = (
|
||||
self.session.query(AssetTagModel.asset_id)
|
||||
.filter(AssetTagModel.tag_id.in_(tag_set))
|
||||
.group_by(AssetTagModel.asset_id)
|
||||
.having(func.count(AssetTagModel.tag_id) == len(tag_set))
|
||||
.all()
|
||||
)
|
||||
ids = [row[0] for row in asset_ids]
|
||||
if not ids:
|
||||
return []
|
||||
models = self.session.query(AssetModel).filter(AssetModel.id.in_(ids)).offset(skip).limit(limit).all()
|
||||
return [self._to_domain(m) for m in models]
|
||||
|
||||
@@ -90,6 +90,29 @@ class AssetModel(Base):
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class TagModel(Base):
|
||||
"""标签 ORM 模型。"""
|
||||
|
||||
__tablename__ = "tags"
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
user_id = Column(String(36), nullable=False, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_tags_user_name"),)
|
||||
|
||||
|
||||
class AssetTagModel(Base):
|
||||
"""素材-标签关联表 ORM 模型。"""
|
||||
|
||||
__tablename__ = "asset_tags"
|
||||
|
||||
asset_id = Column(String(36), primary_key=True)
|
||||
tag_id = Column(String(36), primary_key=True)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class EditTemplateModel(Base):
|
||||
"""Phase 8 剪辑模板 ORM 模型
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""标签 SQLAlchemy 仓储实现。"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetTagModel, TagModel
|
||||
from packages.domain import Tag
|
||||
|
||||
|
||||
class SQLAlchemyTagRepository:
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def create(self, tag: Tag) -> Tag:
|
||||
model = TagModel(
|
||||
id=tag.id,
|
||||
user_id=tag.user_id,
|
||||
name=tag.name,
|
||||
created_at=tag.created_at,
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return tag
|
||||
|
||||
def get(self, tag_id: str) -> Tag | None:
|
||||
model = self.session.query(TagModel).filter(TagModel.id == tag_id).first()
|
||||
if model is None:
|
||||
return None
|
||||
return self._to_domain(model)
|
||||
|
||||
def find_by_name(self, user_id: str, name: str) -> Tag | None:
|
||||
model = self.session.query(TagModel).filter(TagModel.user_id == user_id, TagModel.name == name).first()
|
||||
if model is None:
|
||||
return None
|
||||
return self._to_domain(model)
|
||||
|
||||
def list_by_user(
|
||||
self,
|
||||
user_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> list[Tag]:
|
||||
models = (
|
||||
self.session.query(TagModel)
|
||||
.filter(TagModel.user_id == user_id)
|
||||
.order_by(TagModel.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [self._to_domain(m) for m in models]
|
||||
|
||||
def count_by_user(self, user_id: str) -> int:
|
||||
return self.session.query(TagModel).filter(TagModel.user_id == user_id).count()
|
||||
|
||||
def delete(self, tag_id: str) -> bool:
|
||||
# 先清理关联表
|
||||
self.session.query(AssetTagModel).filter(AssetTagModel.tag_id == tag_id).delete(synchronize_session=False)
|
||||
model = self.session.query(TagModel).filter(TagModel.id == tag_id).first()
|
||||
if model is None:
|
||||
self.session.commit()
|
||||
return False
|
||||
self.session.delete(model)
|
||||
self.session.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _to_domain(model: TagModel) -> Tag:
|
||||
return Tag(
|
||||
id=model.id,
|
||||
user_id=model.user_id,
|
||||
name=model.name,
|
||||
created_at=model.created_at,
|
||||
)
|
||||
Reference in New Issue
Block a user