feat: #1776 素材库 asset_count 自动同步维护 #1787
@@ -208,6 +208,8 @@ def _create_pending_asset(
|
||||
find-or-create:prepare 阶段已按 file_hash/client_upload_id 预建的占位记录
|
||||
会被 find_by_library_and_file_hash/find_by_library_and_client_upload_id 命中,
|
||||
直接复用并补齐字段(避免 pre-create + complete 重复建两条)。
|
||||
|
||||
Issue #1776: 素材库计数由 asset_repository.create() 自动维护。
|
||||
"""
|
||||
# 1. 按 client_upload_id / file_hash 查找现有记录
|
||||
existing = None
|
||||
@@ -389,6 +391,7 @@ async def prepare_direct_upload(
|
||||
file_size=request.file_size,
|
||||
)
|
||||
pending_asset_id = pending.id
|
||||
# Issue #1776: 计数由 asset_repository.create() 自动维护
|
||||
except Exception as error:
|
||||
# 预建失败不阻塞签名:complete 仍可按 OSS 文件 + hash 兜底去重
|
||||
logger.warning("预建 asset 占位失败,降级走 old flow: %s", error)
|
||||
@@ -474,6 +477,7 @@ async def complete_direct_upload(
|
||||
client_upload_id=request.client_upload_id,
|
||||
file_size=request.file_size,
|
||||
)
|
||||
# Issue #1776: 计数由 asset_repository.create() 自动维护
|
||||
|
||||
job = _submit_ingest_job(
|
||||
project_id=request.project_id,
|
||||
@@ -568,6 +572,7 @@ async def upload_asset(
|
||||
file_hash=file_hash,
|
||||
client_upload_id=client_upload_id,
|
||||
)
|
||||
# Issue #1776: 计数由 asset_repository.create() 自动维护
|
||||
|
||||
job = _submit_ingest_job(
|
||||
project_id=project_id,
|
||||
|
||||
@@ -35,18 +35,23 @@ class InMemoryAssetLibraryRepository:
|
||||
return True
|
||||
return False
|
||||
|
||||
def increment_asset_count(self, library_id: str, size_delta: int) -> None:
|
||||
def increment_asset_count(self, library_id: str, count_delta: int = 1, size_delta: int = 0) -> None:
|
||||
library = self._libraries.get(library_id)
|
||||
if library:
|
||||
library.asset_count += 1
|
||||
library.asset_count += count_delta
|
||||
library.total_size += size_delta
|
||||
|
||||
def decrement_asset_count(self, library_id: str, size_delta: int) -> None:
|
||||
def decrement_asset_count(self, library_id: str, count_delta: int = 1, size_delta: int = 0) -> None:
|
||||
library = self._libraries.get(library_id)
|
||||
if library:
|
||||
library.asset_count = max(0, library.asset_count - 1)
|
||||
library.asset_count = max(0, library.asset_count - count_delta)
|
||||
library.total_size = max(0, library.total_size - size_delta)
|
||||
|
||||
def recount_assets(self, library_id: str) -> int:
|
||||
"""InMemory 实现无法真正重算(没有 asset 数据源),返回当前计数。"""
|
||||
library = self._libraries.get(library_id)
|
||||
return library.asset_count if library else 0
|
||||
|
||||
def get_or_create_default_library(
|
||||
self,
|
||||
project_id: str,
|
||||
|
||||
@@ -77,19 +77,79 @@ class SQLAlchemyAssetLibraryRepository:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def increment_asset_count(self, library_id: str, size_delta: int) -> None:
|
||||
model = self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library_id).first()
|
||||
if model:
|
||||
model.asset_count = (model.asset_count or 0) + 1
|
||||
model.total_size = (model.total_size or 0) + size_delta
|
||||
self.session.commit()
|
||||
def increment_asset_count(self, library_id: str, count_delta: int = 1, size_delta: int = 0) -> None:
|
||||
"""原子递增素材计数(Issue #1776)。
|
||||
|
||||
async def decrement_asset_count(self, library_id: str, size_delta: int) -> None:
|
||||
model = self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library_id).first()
|
||||
if model:
|
||||
model.asset_count = max(0, (model.asset_count or 0) - 1)
|
||||
model.total_size = max(0, (model.total_size or 0) - size_delta)
|
||||
self.session.commit()
|
||||
使用 SQL 级 UPDATE 保证并发安全,不单独 commit(由调用方统一事务提交)。
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library_id).update(
|
||||
{
|
||||
AssetLibraryModel.asset_count: func.coalesce(AssetLibraryModel.asset_count, 0) + count_delta,
|
||||
AssetLibraryModel.total_size: func.coalesce(AssetLibraryModel.total_size, 0) + size_delta,
|
||||
}
|
||||
)
|
||||
|
||||
def decrement_asset_count(self, library_id: str, count_delta: int = 1, size_delta: int = 0) -> None:
|
||||
"""原子递减素材计数(Issue #1776),下限为 0 防止负数。
|
||||
|
||||
使用 SQL 级 UPDATE 保证并发安全,不单独 commit(由调用方统一事务提交)。
|
||||
使用 CASE WHEN 兼容 SQLite(测试)和 PostgreSQL(生产)。
|
||||
"""
|
||||
from sqlalchemy import case, func
|
||||
|
||||
self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library_id).update(
|
||||
{
|
||||
AssetLibraryModel.asset_count: case(
|
||||
(func.coalesce(AssetLibraryModel.asset_count, 0) - count_delta < 0, 0),
|
||||
else_=func.coalesce(AssetLibraryModel.asset_count, 0) - count_delta,
|
||||
),
|
||||
AssetLibraryModel.total_size: case(
|
||||
(func.coalesce(AssetLibraryModel.total_size, 0) - size_delta < 0, 0),
|
||||
else_=func.coalesce(AssetLibraryModel.total_size, 0) - size_delta,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
def recount_assets(self, library_id: str) -> int:
|
||||
"""重算素材库计数(Issue #1776)。
|
||||
|
||||
直接查询实际素材数量(排除已删除),更新 asset_count 和 total_size。
|
||||
返回重算后的实际计数。
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
# 查询实际计数(排除 deleted)
|
||||
actual_count = (
|
||||
self.session.query(func.count(AssetModel.id))
|
||||
.filter(
|
||||
AssetModel.asset_library_id == library_id,
|
||||
AssetModel.status != "deleted",
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
# 查询实际总大小
|
||||
actual_size = (
|
||||
self.session.query(func.coalesce(func.sum(AssetModel.file_size), 0))
|
||||
.filter(
|
||||
AssetModel.asset_library_id == library_id,
|
||||
AssetModel.status != "deleted",
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
# 更新素材库记录
|
||||
self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library_id).update(
|
||||
{
|
||||
AssetLibraryModel.asset_count: actual_count,
|
||||
AssetLibraryModel.total_size: actual_size,
|
||||
}
|
||||
)
|
||||
return actual_count
|
||||
|
||||
def get_or_create_default_library(
|
||||
self,
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel, AssetTagModel
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetLibraryModel, AssetModel, AssetTagModel
|
||||
from packages.domain import Asset, AssetStatus, ClassificationStatus
|
||||
|
||||
|
||||
@@ -141,6 +141,16 @@ class SQLAlchemyAssetRepository:
|
||||
self.session.add(model)
|
||||
self.session.flush()
|
||||
self._sync_asset_tags(asset.id, asset.tag_ids)
|
||||
# Issue #1776: 自动维护素材库计数(同事务内原子更新)
|
||||
if asset.library_id and asset.status.value != "deleted":
|
||||
from sqlalchemy import func
|
||||
|
||||
self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == asset.library_id).update(
|
||||
{
|
||||
AssetLibraryModel.asset_count: func.coalesce(AssetLibraryModel.asset_count, 0) + 1,
|
||||
AssetLibraryModel.total_size: func.coalesce(AssetLibraryModel.total_size, 0) + asset.file_size,
|
||||
}
|
||||
)
|
||||
self.session.commit()
|
||||
return asset
|
||||
|
||||
@@ -175,7 +185,27 @@ class SQLAlchemyAssetRepository:
|
||||
def delete(self, asset_id: str) -> bool:
|
||||
model = self.session.query(AssetModel).filter(AssetModel.id == asset_id).first()
|
||||
if model:
|
||||
library_id = model.asset_library_id
|
||||
file_size = model.file_size or 0
|
||||
# 只统计非 deleted 状态的素材
|
||||
was_counted = model.status != "deleted"
|
||||
self.session.delete(model)
|
||||
# Issue #1776: 自动维护素材库计数
|
||||
if library_id and was_counted:
|
||||
from sqlalchemy import case, func
|
||||
|
||||
self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library_id).update(
|
||||
{
|
||||
AssetLibraryModel.asset_count: case(
|
||||
(func.coalesce(AssetLibraryModel.asset_count, 0) - 1 < 0, 0),
|
||||
else_=func.coalesce(AssetLibraryModel.asset_count, 0) - 1,
|
||||
),
|
||||
AssetLibraryModel.total_size: case(
|
||||
(func.coalesce(AssetLibraryModel.total_size, 0) - file_size < 0, 0),
|
||||
else_=func.coalesce(AssetLibraryModel.total_size, 0) - file_size,
|
||||
),
|
||||
}
|
||||
)
|
||||
self.session.commit()
|
||||
return True
|
||||
return False
|
||||
@@ -187,11 +217,44 @@ class SQLAlchemyAssetRepository:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
# 先查询待删除素材的库分布(用于更新计数)
|
||||
to_delete = (
|
||||
self.session.query(AssetModel.asset_library_id, AssetModel.file_size)
|
||||
.filter(AssetModel.id.in_(asset_ids), AssetModel.status != "deleted")
|
||||
.all()
|
||||
)
|
||||
if not to_delete:
|
||||
return 0
|
||||
# 按库分组统计
|
||||
library_deltas: dict[str, tuple[int, int]] = {} # library_id -> (count_delta, size_delta)
|
||||
for lib_id, size in to_delete:
|
||||
if lib_id not in library_deltas:
|
||||
library_deltas[lib_id] = (0, 0)
|
||||
c, s = library_deltas[lib_id]
|
||||
library_deltas[lib_id] = (c + 1, s + (size or 0))
|
||||
# 执行软删除
|
||||
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)
|
||||
)
|
||||
# Issue #1776: 自动维护各素材库计数
|
||||
if library_deltas:
|
||||
from sqlalchemy import case, func
|
||||
|
||||
for lib_id, (count_delta, size_delta) in library_deltas.items():
|
||||
self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == lib_id).update(
|
||||
{
|
||||
AssetLibraryModel.asset_count: case(
|
||||
(func.coalesce(AssetLibraryModel.asset_count, 0) - count_delta < 0, 0),
|
||||
else_=func.coalesce(AssetLibraryModel.asset_count, 0) - count_delta,
|
||||
),
|
||||
AssetLibraryModel.total_size: case(
|
||||
(func.coalesce(AssetLibraryModel.total_size, 0) - size_delta < 0, 0),
|
||||
else_=func.coalesce(AssetLibraryModel.total_size, 0) - size_delta,
|
||||
),
|
||||
}
|
||||
)
|
||||
self.session.commit()
|
||||
return count
|
||||
|
||||
|
||||
@@ -30,9 +30,16 @@ class AssetLibraryRepository(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def increment_asset_count(self, library_id: str, size_delta: int) -> None:
|
||||
def increment_asset_count(self, library_id: str, count_delta: int = 1, size_delta: int = 0) -> None:
|
||||
"""原子递增素材计数(Issue #1776)。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def decrement_asset_count(self, library_id: str, size_delta: int) -> None:
|
||||
def decrement_asset_count(self, library_id: str, count_delta: int = 1, size_delta: int = 0) -> None:
|
||||
"""原子递减素材计数(Issue #1776)。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def recount_assets(self, library_id: str) -> int:
|
||||
"""重算素材库计数(Issue #1776)。"""
|
||||
pass
|
||||
|
||||
Executable
+159
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""素材库计数重算脚本(Issue #1776)。
|
||||
|
||||
用法:
|
||||
# Dry-run: 输出差异清单,不执行修改
|
||||
python scripts/recount_asset_counts.py --dry-run
|
||||
|
||||
# 执行修正
|
||||
python scripts/recount_asset_counts.py
|
||||
|
||||
# 只处理指定项目
|
||||
python scripts/recount_asset_counts.py --project-id <project_id>
|
||||
|
||||
# 只处理指定素材库
|
||||
python scripts/recount_asset_counts.py --library-id <library_id>
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录到 path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy import create_engine, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetLibraryModel, AssetModel
|
||||
|
||||
|
||||
def get_db_session() -> Session:
|
||||
"""创建数据库 session。"""
|
||||
import os
|
||||
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
print("ERROR: DATABASE_URL environment variable not set")
|
||||
sys.exit(1)
|
||||
engine = create_engine(database_url)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def check_discrepancies(session: Session, project_id: str | None = None, library_id: str | None = None) -> list[dict]:
|
||||
"""检查素材库计数差异。
|
||||
|
||||
返回列表,每项包含:
|
||||
- library_id: 素材库 ID
|
||||
- library_name: 素材库名称
|
||||
- recorded_count: 记录的计数
|
||||
- actual_count: 实际计数
|
||||
- delta: 差异 (actual - recorded)
|
||||
"""
|
||||
query = session.query(AssetLibraryModel)
|
||||
if project_id:
|
||||
query = query.filter(AssetLibraryModel.project_id == project_id)
|
||||
if library_id:
|
||||
query = query.filter(AssetLibraryModel.id == library_id)
|
||||
|
||||
libraries = query.all()
|
||||
discrepancies = []
|
||||
|
||||
for lib in libraries:
|
||||
# 查询实际计数(排除 deleted)
|
||||
actual_count = (
|
||||
session.query(func.count(AssetModel.id))
|
||||
.filter(
|
||||
AssetModel.asset_library_id == lib.id,
|
||||
AssetModel.status != "deleted",
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
actual_size = (
|
||||
session.query(func.coalesce(func.sum(AssetModel.file_size), 0))
|
||||
.filter(
|
||||
AssetModel.asset_library_id == lib.id,
|
||||
AssetModel.status != "deleted",
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
recorded_count = int(lib.asset_count or 0)
|
||||
recorded_size = int(lib.total_size or 0)
|
||||
|
||||
if actual_count != recorded_count or actual_size != recorded_size:
|
||||
discrepancies.append(
|
||||
{
|
||||
"library_id": lib.id,
|
||||
"library_name": lib.name,
|
||||
"project_id": lib.project_id,
|
||||
"kind": lib.kind,
|
||||
"recorded_count": recorded_count,
|
||||
"actual_count": actual_count,
|
||||
"count_delta": actual_count - recorded_count,
|
||||
"recorded_size": recorded_size,
|
||||
"actual_size": actual_size,
|
||||
"size_delta": actual_size - recorded_size,
|
||||
}
|
||||
)
|
||||
|
||||
return discrepancies
|
||||
|
||||
|
||||
def fix_discrepancies(session: Session, discrepancies: list[dict]) -> int:
|
||||
"""修正素材库计数。返回修正数量。"""
|
||||
fixed = 0
|
||||
for d in discrepancies:
|
||||
session.query(AssetLibraryModel).filter(AssetLibraryModel.id == d["library_id"]).update(
|
||||
{
|
||||
AssetLibraryModel.asset_count: d["actual_count"],
|
||||
AssetLibraryModel.total_size: d["actual_size"],
|
||||
}
|
||||
)
|
||||
fixed += 1
|
||||
session.commit()
|
||||
return fixed
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="素材库计数重算脚本(Issue #1776)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只输出差异清单,不执行修正")
|
||||
parser.add_argument("--project-id", type=str, help="只处理指定项目")
|
||||
parser.add_argument("--library-id", type=str, help="只处理指定素材库")
|
||||
args = parser.parse_args()
|
||||
|
||||
session = get_db_session()
|
||||
|
||||
try:
|
||||
discrepancies = check_discrepancies(session, args.project_id, args.library_id)
|
||||
|
||||
if not discrepancies:
|
||||
print("✅ 所有素材库计数一致,无需修正")
|
||||
return
|
||||
|
||||
# 输出差异清单
|
||||
print(f"发现 {len(discrepancies)} 个素材库计数不一致:\n")
|
||||
print(f"{'Library ID':<40} {'Name':<20} {'Recorded':<10} {'Actual':<10} {'Delta':<10}")
|
||||
print("-" * 90)
|
||||
for d in discrepancies:
|
||||
print(
|
||||
f"{d['library_id']:<40} {d['library_name'][:20]:<20} {d['recorded_count']:<10} {d['actual_count']:<10} {d['count_delta']:+<10}"
|
||||
)
|
||||
|
||||
total_delta = sum(d["count_delta"] for d in discrepancies)
|
||||
print(f"\n总计差异: {total_delta:+d}")
|
||||
|
||||
if args.dry_run:
|
||||
print("\n[DRY-RUN] 未执行修正。移除 --dry-run 参数以执行修正。")
|
||||
else:
|
||||
print("\n正在执行修正...")
|
||||
fixed = fix_discrepancies(session, discrepancies)
|
||||
print(f"✅ 已修正 {fixed} 个素材库计数")
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Issue #1776: asset_libraries.asset_count 同步维护测试。
|
||||
|
||||
覆盖场景:
|
||||
1. 素材创建 → count +1
|
||||
2. 素材硬删除 → count -1
|
||||
3. 素材软删除(batch_delete)→ count -N
|
||||
4. 幂等上传(prepare 占位 + complete 复用)→ 不重复计数
|
||||
5. 重试场景(complete 重试)→ 不重复计数
|
||||
6. recount 方法修正计数
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_library_repository import SQLAlchemyAssetLibraryRepository
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetLibraryModel, AssetModel, Base
|
||||
from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_session():
|
||||
"""创建测试用 SQLite 内存数据库。"""
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
session = Session(engine)
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def asset_repo(db_session):
|
||||
return SQLAlchemyAssetRepository(db_session)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def library_repo(db_session):
|
||||
return SQLAlchemyAssetLibraryRepository(db_session)
|
||||
|
||||
|
||||
def _make_library(project_id: str, kind: AssetLibraryKind = AssetLibraryKind.VIDEO) -> AssetLibrary:
|
||||
return AssetLibrary.create(project_id=project_id, name=f"测试{kind.value}库", kind=kind)
|
||||
|
||||
|
||||
def _make_asset(
|
||||
library_id: str,
|
||||
project_id: str,
|
||||
*,
|
||||
status: AssetStatus = AssetStatus.PROCESSING,
|
||||
file_size: int = 1024,
|
||||
file_hash: str = "",
|
||||
client_upload_id: str = "",
|
||||
) -> Asset:
|
||||
return Asset.create(
|
||||
project_id=project_id,
|
||||
library_id=library_id,
|
||||
name=f"test_{uuid.uuid4().hex[:8]}.mp4",
|
||||
storage_key=f"uploads/{uuid.uuid4().hex[:8]}/test.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=status,
|
||||
uploaded_by_user_id="test-user",
|
||||
file_hash=file_hash,
|
||||
client_upload_id=client_upload_id,
|
||||
file_size=file_size,
|
||||
)
|
||||
|
||||
|
||||
class TestAssetCountOnCreate:
|
||||
"""素材创建时计数递增。"""
|
||||
|
||||
def test_create_asset_increments_count(self, library_repo, asset_repo, db_session):
|
||||
"""创建一个素材 → count 从 0 变 1。"""
|
||||
library = library_repo.create(_make_library("project-1"))
|
||||
assert library.asset_count == 0
|
||||
|
||||
asset = _make_asset(library.id, "project-1")
|
||||
asset_repo.create(asset)
|
||||
|
||||
# 重新查询验证计数
|
||||
updated_library = library_repo.get(library.id)
|
||||
assert updated_library.asset_count == 1
|
||||
assert updated_library.total_size == 1024
|
||||
|
||||
def test_create_multiple_assets_increments_count(self, library_repo, asset_repo, db_session):
|
||||
"""创建多个素材 → count 累加。"""
|
||||
library = library_repo.create(_make_library("project-1"))
|
||||
|
||||
for _i in range(3):
|
||||
asset = _make_asset(library.id, "project-1", file_size=100 * (_i + 1))
|
||||
asset_repo.create(asset)
|
||||
|
||||
updated_library = library_repo.get(library.id)
|
||||
assert updated_library.asset_count == 3
|
||||
assert updated_library.total_size == 100 + 200 + 300
|
||||
|
||||
|
||||
class TestAssetCountOnDelete:
|
||||
"""素材删除时计数递减。"""
|
||||
|
||||
def test_hard_delete_decrements_count(self, library_repo, asset_repo, db_session):
|
||||
"""硬删除素材 → count -1。"""
|
||||
library = library_repo.create(_make_library("project-1"))
|
||||
asset = asset_repo.create(_make_asset(library.id, "project-1"))
|
||||
assert library_repo.get(library.id).asset_count == 1
|
||||
|
||||
asset_repo.delete(asset.id)
|
||||
|
||||
assert library_repo.get(library.id).asset_count == 0
|
||||
|
||||
def test_hard_delete_already_deleted_no_change(self, library_repo, asset_repo, db_session):
|
||||
"""删除已删除的素材 → count 不变。"""
|
||||
library = library_repo.create(_make_library("project-1"))
|
||||
asset = asset_repo.create(_make_asset(library.id, "project-1"))
|
||||
# 先软删除(count 已经 -1)
|
||||
asset_repo.batch_delete([asset.id])
|
||||
assert library_repo.get(library.id).asset_count == 0
|
||||
|
||||
# 再硬删除(不应再 -1)
|
||||
asset_repo.delete(asset.id)
|
||||
assert library_repo.get(library.id).asset_count == 0
|
||||
|
||||
def test_batch_delete_decrements_count(self, library_repo, asset_repo, db_session):
|
||||
"""批量软删除 → count -N。"""
|
||||
library = library_repo.create(_make_library("project-1"))
|
||||
asset_ids = []
|
||||
for _i in range(5):
|
||||
asset = asset_repo.create(_make_asset(library.id, "project-1", file_size=200))
|
||||
asset_ids.append(asset.id)
|
||||
assert library_repo.get(library.id).asset_count == 5
|
||||
|
||||
# 删除 3 个
|
||||
deleted_count = asset_repo.batch_delete(asset_ids[:3])
|
||||
assert deleted_count == 3
|
||||
assert library_repo.get(library.id).asset_count == 2
|
||||
assert library_repo.get(library.id).total_size == 200 * 2
|
||||
|
||||
def test_batch_delete_skips_already_deleted(self, library_repo, asset_repo, db_session):
|
||||
"""批量删除已删除的素材 → count 不变。"""
|
||||
library = library_repo.create(_make_library("project-1"))
|
||||
asset_ids = []
|
||||
for _i in range(3):
|
||||
asset = asset_repo.create(_make_asset(library.id, "project-1"))
|
||||
asset_ids.append(asset.id)
|
||||
assert library_repo.get(library.id).asset_count == 3
|
||||
|
||||
# 先删除 2 个
|
||||
asset_repo.batch_delete(asset_ids[:2])
|
||||
assert library_repo.get(library.id).asset_count == 1
|
||||
|
||||
# 再删除同样的 2 个(应被跳过)
|
||||
deleted_count = asset_repo.batch_delete(asset_ids[:2])
|
||||
assert deleted_count == 0
|
||||
assert library_repo.get(library.id).asset_count == 1
|
||||
|
||||
def test_count_never_negative(self, library_repo, asset_repo, db_session):
|
||||
"""计数下限为 0,不会出现负数。"""
|
||||
library = library_repo.create(_make_library("project-1"))
|
||||
# 手动设置计数为 0
|
||||
library.asset_count = 0
|
||||
library_repo.update(library)
|
||||
|
||||
# 尝试递减(通过直接调用 decrement)
|
||||
library_repo.decrement_asset_count(library.id, count_delta=5)
|
||||
db_session.commit()
|
||||
|
||||
updated = library_repo.get(library.id)
|
||||
assert updated.asset_count == 0
|
||||
|
||||
|
||||
class TestIdempotentUpload:
|
||||
"""幂等上传场景:不重复计数。"""
|
||||
|
||||
def test_prepare_then_complete_no_double_count(self, library_repo, asset_repo, db_session):
|
||||
"""prepare 创建占位 + complete 复用占位 → count 只 +1。"""
|
||||
library = library_repo.create(_make_library("project-1"))
|
||||
|
||||
# prepare 阶段:创建占位
|
||||
placeholder = _make_asset(
|
||||
library.id,
|
||||
"project-1",
|
||||
status=AssetStatus.PROCESSING,
|
||||
client_upload_id="upload-token-123",
|
||||
)
|
||||
asset_repo.create(placeholder)
|
||||
assert library_repo.get(library.id).asset_count == 1
|
||||
|
||||
# complete 阶段:查找已有占位并复用(通过 client_upload_id)
|
||||
existing = asset_repo.find_by_library_and_client_upload_id(
|
||||
library_id=library.id,
|
||||
client_upload_id="upload-token-123",
|
||||
)
|
||||
assert existing is not None
|
||||
# 复用占位,不创建新记录 → count 不变
|
||||
assert library_repo.get(library.id).asset_count == 1
|
||||
|
||||
def test_complete_retry_no_double_count(self, library_repo, asset_repo, db_session):
|
||||
"""complete 重试(通过 file_hash 去重)→ count 只 +1。"""
|
||||
library = library_repo.create(_make_library("project-1"))
|
||||
|
||||
# 第一次 complete:创建素材
|
||||
asset1 = _make_asset(
|
||||
library.id,
|
||||
"project-1",
|
||||
file_hash="hash-abc-123",
|
||||
)
|
||||
asset_repo.create(asset1)
|
||||
assert library_repo.get(library.id).asset_count == 1
|
||||
|
||||
# 重试 complete:通过 file_hash 查找已有
|
||||
existing = asset_repo.find_by_library_and_file_hash(
|
||||
library_id=library.id,
|
||||
file_hash="hash-abc-123",
|
||||
)
|
||||
assert existing is not None
|
||||
assert existing.id == asset1.id
|
||||
# 不创建新记录 → count 不变
|
||||
assert library_repo.get(library.id).asset_count == 1
|
||||
|
||||
|
||||
class TestRecountAssets:
|
||||
"""recount_assets 方法修正计数。"""
|
||||
|
||||
def test_recount_fixes_drift(self, library_repo, asset_repo, db_session):
|
||||
"""计数漂移后,recount 能修正。"""
|
||||
library = library_repo.create(_make_library("project-1"))
|
||||
# 创建 3 个素材
|
||||
for _ in range(3):
|
||||
asset_repo.create(_make_asset(library.id, "project-1"))
|
||||
assert library_repo.get(library.id).asset_count == 3
|
||||
|
||||
# 手动破坏计数(模拟历史数据问题)
|
||||
db_session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library.id).update(
|
||||
{AssetLibraryModel.asset_count: 999, AssetLibraryModel.total_size: 999999}
|
||||
)
|
||||
db_session.commit()
|
||||
assert library_repo.get(library.id).asset_count == 999
|
||||
|
||||
# recount 修正
|
||||
actual = library_repo.recount_assets(library.id)
|
||||
db_session.commit()
|
||||
|
||||
assert actual == 3
|
||||
assert library_repo.get(library.id).asset_count == 3
|
||||
assert library_repo.get(library.id).total_size == 1024 * 3
|
||||
|
||||
def test_recount_excludes_deleted(self, library_repo, asset_repo, db_session):
|
||||
"""recount 排除已删除素材。"""
|
||||
library = library_repo.create(_make_library("project-1"))
|
||||
assets = []
|
||||
for _ in range(5):
|
||||
asset = asset_repo.create(_make_asset(library.id, "project-1"))
|
||||
assets.append(asset)
|
||||
assert library_repo.get(library.id).asset_count == 5
|
||||
|
||||
# 软删除 2 个
|
||||
asset_repo.batch_delete([assets[0].id, assets[1].id])
|
||||
assert library_repo.get(library.id).asset_count == 3
|
||||
|
||||
# 破坏计数
|
||||
db_session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library.id).update(
|
||||
{AssetLibraryModel.asset_count: 100}
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
# recount 应排除 deleted
|
||||
actual = library_repo.recount_assets(library.id)
|
||||
db_session.commit()
|
||||
|
||||
assert actual == 3
|
||||
assert library_repo.get(library.id).asset_count == 3
|
||||
|
||||
|
||||
class TestConcurrentSafety:
|
||||
"""并发安全测试(SQLite 模拟有限并发)。"""
|
||||
|
||||
def test_increment_is_atomic(self, library_repo, db_session):
|
||||
"""increment_asset_count 使用 SQL 级 UPDATE,并发安全。"""
|
||||
library = library_repo.create(_make_library("project-1"))
|
||||
|
||||
# 多次递增
|
||||
for _ in range(10):
|
||||
library_repo.increment_asset_count(library.id, count_delta=1, size_delta=100)
|
||||
db_session.commit()
|
||||
|
||||
updated = library_repo.get(library.id)
|
||||
assert updated.asset_count == 10
|
||||
assert updated.total_size == 1000
|
||||
|
||||
def test_decrement_with_floor_zero(self, library_repo, db_session):
|
||||
"""decrement_asset_count 下限为 0。"""
|
||||
library = library_repo.create(_make_library("project-1"))
|
||||
library.asset_count = 3
|
||||
library_repo.update(library)
|
||||
|
||||
# 尝试递减 10 次
|
||||
for _ in range(10):
|
||||
library_repo.decrement_asset_count(library.id, count_delta=1)
|
||||
db_session.commit()
|
||||
|
||||
updated = library_repo.get(library.id)
|
||||
assert updated.asset_count == 0
|
||||
@@ -128,12 +128,12 @@ class TestAssetLibraryRepoCounting:
|
||||
assert lib.asset_count == 0
|
||||
assert lib.total_size == 0
|
||||
|
||||
repo.increment_asset_count(lib.id, 1024)
|
||||
repo.increment_asset_count(lib.id, size_delta=1024)
|
||||
fetched = repo.get(lib.id)
|
||||
assert fetched.asset_count == 1
|
||||
assert fetched.total_size == 1024
|
||||
|
||||
repo.increment_asset_count(lib.id, 2048)
|
||||
repo.increment_asset_count(lib.id, size_delta=2048)
|
||||
fetched = repo.get(lib.id)
|
||||
assert fetched.asset_count == 2
|
||||
assert fetched.total_size == 3072
|
||||
@@ -144,7 +144,7 @@ class TestAssetLibraryRepoCounting:
|
||||
lib.total_size = 3000
|
||||
repo.create(lib)
|
||||
|
||||
repo.decrement_asset_count(lib.id, 1000)
|
||||
repo.decrement_asset_count(lib.id, size_delta=1000)
|
||||
fetched = repo.get(lib.id)
|
||||
assert fetched.asset_count == 2
|
||||
assert fetched.total_size == 2000
|
||||
@@ -157,14 +157,14 @@ class TestAssetLibraryRepoCounting:
|
||||
repo.create(lib)
|
||||
|
||||
# 减 2 次,应该被钳制到 0
|
||||
repo.decrement_asset_count(lib.id, 200)
|
||||
repo.decrement_asset_count(lib.id, size_delta=200)
|
||||
fetched = repo.get(lib.id)
|
||||
assert fetched.asset_count == 0
|
||||
assert fetched.total_size == 0
|
||||
|
||||
def test_increment_nonexistent_library_no_error(self, repo):
|
||||
"""对不存在的素材库操作,不抛异常也无效果."""
|
||||
repo.increment_asset_count("nonexistent", 100)
|
||||
repo.increment_asset_count("nonexistent", size_delta=100)
|
||||
# 不报错
|
||||
assert repo.get("nonexistent") is None
|
||||
|
||||
|
||||
@@ -97,40 +97,40 @@ class TestInMemoryAssetLibraryRepository:
|
||||
|
||||
def test_increment_asset_count(self, repo, lib_video):
|
||||
repo.create(lib_video)
|
||||
repo.increment_asset_count(lib_video.id, 1024)
|
||||
repo.increment_asset_count(lib_video.id, size_delta=1024)
|
||||
|
||||
lib = repo.get(lib_video.id)
|
||||
assert lib.asset_count == 1
|
||||
assert lib.total_size == 1024
|
||||
|
||||
repo.increment_asset_count(lib_video.id, 512)
|
||||
repo.increment_asset_count(lib_video.id, size_delta=512)
|
||||
lib = repo.get(lib_video.id)
|
||||
assert lib.asset_count == 2
|
||||
assert lib.total_size == 1536
|
||||
|
||||
def test_increment_asset_count_nonexistent(self, repo):
|
||||
# 不报错,静默忽略
|
||||
repo.increment_asset_count("nonexistent", 100)
|
||||
repo.increment_asset_count("nonexistent", size_delta=100)
|
||||
|
||||
def test_decrement_asset_count(self, repo, lib_video):
|
||||
repo.create(lib_video)
|
||||
repo.increment_asset_count(lib_video.id, 1024)
|
||||
repo.increment_asset_count(lib_video.id, 512)
|
||||
repo.increment_asset_count(lib_video.id, size_delta=1024)
|
||||
repo.increment_asset_count(lib_video.id, size_delta=512)
|
||||
|
||||
repo.decrement_asset_count(lib_video.id, 512)
|
||||
repo.decrement_asset_count(lib_video.id, size_delta=512)
|
||||
lib = repo.get(lib_video.id)
|
||||
assert lib.asset_count == 1
|
||||
assert lib.total_size == 1024
|
||||
|
||||
def test_decrement_asset_count_not_below_zero(self, repo, lib_video):
|
||||
repo.create(lib_video)
|
||||
repo.decrement_asset_count(lib_video.id, 9999)
|
||||
repo.decrement_asset_count(lib_video.id, size_delta=9999)
|
||||
lib = repo.get(lib_video.id)
|
||||
assert lib.asset_count == 0
|
||||
assert lib.total_size == 0
|
||||
|
||||
def test_decrement_asset_count_nonexistent(self, repo):
|
||||
repo.decrement_asset_count("nonexistent", 100)
|
||||
repo.decrement_asset_count("nonexistent", size_delta=100)
|
||||
|
||||
|
||||
# ==================== Tag ====================
|
||||
|
||||
Reference in New Issue
Block a user