diff --git a/apps/api/app/api/routes/upload.py b/apps/api/app/api/routes/upload.py index 08aed4e7b..dcf4f9927 100644 --- a/apps/api/app/api/routes/upload.py +++ b/apps/api/app/api/routes/upload.py @@ -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, diff --git a/packages/adapters/in_memory/asset_library_repository.py b/packages/adapters/in_memory/asset_library_repository.py index a758546a5..b246ec6ed 100644 --- a/packages/adapters/in_memory/asset_library_repository.py +++ b/packages/adapters/in_memory/asset_library_repository.py @@ -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, diff --git a/packages/adapters/sqlalchemy_impl/asset_library_repository.py b/packages/adapters/sqlalchemy_impl/asset_library_repository.py index fe0998a51..215bbdd02 100644 --- a/packages/adapters/sqlalchemy_impl/asset_library_repository.py +++ b/packages/adapters/sqlalchemy_impl/asset_library_repository.py @@ -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, diff --git a/packages/adapters/sqlalchemy_impl/asset_repository.py b/packages/adapters/sqlalchemy_impl/asset_repository.py index fad219727..88f9dceb2 100755 --- a/packages/adapters/sqlalchemy_impl/asset_repository.py +++ b/packages/adapters/sqlalchemy_impl/asset_repository.py @@ -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 diff --git a/packages/ports/asset_library_repository.py b/packages/ports/asset_library_repository.py index 4c7adc960..bd63aaa57 100644 --- a/packages/ports/asset_library_repository.py +++ b/packages/ports/asset_library_repository.py @@ -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 diff --git a/scripts/recount_asset_counts.py b/scripts/recount_asset_counts.py new file mode 100755 index 000000000..5ae8ca7a7 --- /dev/null +++ b/scripts/recount_asset_counts.py @@ -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 + + # 只处理指定素材库 + python scripts/recount_asset_counts.py --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() diff --git a/tests/unit/test_asset_count_sync_1776.py b/tests/unit/test_asset_count_sync_1776.py new file mode 100644 index 000000000..767b1d648 --- /dev/null +++ b/tests/unit/test_asset_count_sync_1776.py @@ -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 diff --git a/tests/unit/test_in_memory_asset_library_repository.py b/tests/unit/test_in_memory_asset_library_repository.py index 66443102c..ef159d7fa 100755 --- a/tests/unit/test_in_memory_asset_library_repository.py +++ b/tests/unit/test_in_memory_asset_library_repository.py @@ -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 diff --git a/tests/unit/test_inmemory_small_repos.py b/tests/unit/test_inmemory_small_repos.py index 87ed5f667..efeab813d 100755 --- a/tests/unit/test_inmemory_small_repos.py +++ b/tests/unit/test_inmemory_small_repos.py @@ -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 ====================