diff --git a/app/routers/clouds.py b/app/routers/clouds.py index e271ab4..638f10a 100644 --- a/app/routers/clouds.py +++ b/app/routers/clouds.py @@ -74,6 +74,54 @@ async def _get_cloud(db: DbSession, cloud_id: uuid.UUID) -> Cloud | None: return result.scalar_one_or_none() +async def _sync_collections_after_cloud_removal( + db: DbSession, + user_id: uuid.UUID, + cloud_type_ids: set[int], +) -> None: + """Keep collection entries aligned with the user's remaining typed clouds.""" + if not cloud_type_ids: + return + + result = await db.execute( + select(UserCollection).where( + UserCollection.user_id == user_id, + UserCollection.cloud_type_id.in_(cloud_type_ids), + ) + ) + collections_by_type = { + item.cloud_type_id: item for item in result.scalars().all() + } + removed_count = 0 + + for cloud_type_id in cloud_type_ids: + collection = collections_by_type.get(cloud_type_id) + if not collection: + continue + + replacement_cloud_id = await db.scalar( + select(Cloud.id) + .where( + Cloud.user_id == user_id, + Cloud.cloud_type_id == cloud_type_id, + ) + .order_by(Cloud.created_at.asc(), Cloud.id.asc()) + .limit(1) + ) + if replacement_cloud_id is None: + await db.delete(collection) + removed_count += 1 + elif collection.first_cloud_id != replacement_cloud_id: + collection.first_cloud_id = replacement_cloud_id + + if removed_count: + await db.execute( + update(User) + .where(User.id == user_id) + .values(collection_count=User.collection_count - removed_count) + ) + + @router.get("", response_model=PageOut[CloudOut]) async def list_gallery_clouds( db: DbSession, @@ -335,9 +383,12 @@ async def delete_cloud(cloud_id: uuid.UUID, db: DbSession, user: CurrentUser) -> if cloud.user_id != user.id and user.role != "admin": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能删除其他用户的图片") paths = [cloud.image_path, cloud.thumbnail_path] + cloud_type_ids = {cloud.cloud_type_id} if cloud.cloud_type_id is not None else set() was_public = cloud.status == "approved" and not cloud.is_hidden await db.delete(cloud) + await db.flush() + await _sync_collections_after_cloud_removal(db, cloud.user_id, cloud_type_ids) await db.execute( update(User) @@ -370,9 +421,12 @@ async def batch_delete_clouds( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部分图片不存在或不属于当前用户") public_count = sum(1 for c in clouds if c.status == "approved" and not c.is_hidden) + cloud_type_ids = {c.cloud_type_id for c in clouds if c.cloud_type_id is not None} paths = [path for cloud in clouds for path in (cloud.image_path, cloud.thumbnail_path)] for cloud in clouds: await db.delete(cloud) + await db.flush() + await _sync_collections_after_cloud_removal(db, user.id, cloud_type_ids) await db.execute( update(User) diff --git a/tests/test_collections.py b/tests/test_collections.py index d6c7756..0193a03 100644 --- a/tests/test_collections.py +++ b/tests/test_collections.py @@ -2,7 +2,7 @@ import uuid from fastapi.testclient import TestClient -from tests.conftest import bearer, png_bytes, register_and_confirm +from tests.conftest import bearer, png_bytes, register_and_confirm, upload_cloud def test_my_collections_after_upload(client: TestClient, monkeypatch) -> None: @@ -30,3 +30,64 @@ def test_my_collections_after_upload(client: TestClient, monkeypatch) -> None: def test_my_collections_unauthenticated(client: TestClient) -> None: r = client.get("/api/v1/collections/me") assert r.status_code == 401 + + +def test_collection_removed_after_deleting_last_cloud(client: TestClient, monkeypatch) -> None: + info = register_and_confirm( + client, monkeypatch, + f"coll-delete-{uuid.uuid4().hex[:8]}@example.com", + "collpass1", "图鉴删除用户", + ) + upload = upload_cloud(client, info["token"], cloud_type_id="1") + cloud_id = upload.json()["cloud"]["id"] + + r = client.delete(f"/api/v1/clouds/{cloud_id}", headers=bearer(info["token"])) + assert r.status_code == 200 + + r = client.get("/api/v1/collections/me", headers=bearer(info["token"])) + assert r.status_code == 200 + assert r.json() == [] + + r = client.get("/api/v1/profiles/me", headers=bearer(info["token"])) + assert r.status_code == 200 + assert r.json()["collection_count"] == 0 + + +def test_collection_uses_next_cloud_after_deleting_first(client: TestClient, monkeypatch) -> None: + info = register_and_confirm( + client, monkeypatch, + f"coll-replace-{uuid.uuid4().hex[:8]}@example.com", + "collpass1", "图鉴替换用户", + ) + first = upload_cloud(client, info["token"], cloud_type_id="1").json()["cloud"] + second = upload_cloud(client, info["token"], cloud_type_id="1").json()["cloud"] + + r = client.delete(f"/api/v1/clouds/{first['id']}", headers=bearer(info["token"])) + assert r.status_code == 200 + + r = client.get("/api/v1/collections/me", headers=bearer(info["token"])) + assert r.status_code == 200 + assert len(r.json()) == 1 + assert r.json()[0]["first_cloud_id"] == second["id"] + assert r.json()[0]["first_cloud"]["id"] == second["id"] + + +def test_batch_delete_removes_affected_collections(client: TestClient, monkeypatch) -> None: + info = register_and_confirm( + client, monkeypatch, + f"coll-batch-{uuid.uuid4().hex[:8]}@example.com", + "collpass1", "图鉴批删用户", + ) + first = upload_cloud(client, info["token"], cloud_type_id="1").json()["cloud"] + second = upload_cloud(client, info["token"], cloud_type_id="2").json()["cloud"] + + r = client.post( + "/api/v1/clouds/batch-delete", + headers=bearer(info["token"]), + json={"ids": [first["id"], second["id"]]}, + ) + assert r.status_code == 200 + + r = client.get("/api/v1/collections/me", headers=bearer(info["token"])) + assert r.status_code == 200 + assert r.json() == []