From 49df3f067a5601a1fc0fccf68a36372cc8bcc4e5 Mon Sep 17 00:00:00 2001 From: Mplan Date: Sun, 26 Jul 2026 02:10:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=82=B9=E8=B5=9E=E6=94=B6?= =?UTF-8?q?=E8=97=8F=E5=8A=9F=E8=83=BD=EF=BC=9Acloud=5Ffavorites=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E3=80=81=E6=9F=A5=E8=AF=A2/=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E7=82=B9=E8=B5=9E=E7=8A=B6=E6=80=81=E5=8F=8A=E6=94=B6?= =?UTF-8?q?=E8=97=8F=E5=88=97=E8=A1=A8=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 40 +++- .../versions/20260726_0002_cloud_favorites.py | 50 ++++ app/main.py | 3 +- app/models.py | 30 +++ app/routers/favorites.py | 178 ++++++++++++++ app/schemas.py | 13 ++ tests/test_favorites.py | 219 ++++++++++++++++++ 7 files changed, 531 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/20260726_0002_cloud_favorites.py create mode 100644 app/routers/favorites.py create mode 100644 tests/test_favorites.py diff --git a/README.md b/README.md index 7893d52..d035a27 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ app/ clouds.py # 云图 cloud_types.py # 云类型 collections.py # 图鉴 + favorites.py # 照片点赞收藏 profiles.py # 用户资料 admin.py # 管理后台 health.py # 健康检查 @@ -159,6 +160,42 @@ fetch(url, { credentials: 'include' }) seed 会写入 10 种标准云类型(积云、层云、卷云等),含中英文名、云族与稀有度。 +### 点赞收藏 + +对照片的点赞/收藏(与云类型「图鉴」不同)。数据表 `cloud_favorites`:`(user_id, cloud_id)` 唯一。 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/api/v1/clouds/{id}/favorite` | 查询喜欢状态(需登录) | +| `PUT` | `/api/v1/clouds/{id}/favorite` | 设置或取消收藏(需登录) | +| `GET` | `/api/v1/favorites/me` | 当前用户收藏列表分页(按收藏时间倒序) | + +查询喜欢状态(默认当前用户;管理员可传 `user_id` 查他人): + +```http +GET /api/v1/clouds/{id}/favorite +GET /api/v1/clouds/{id}/favorite?user_id= +``` + +设置/取消请求体: + +```json +{ "favorited": true } +``` + +响应: + +```json +{ + "cloud_id": "…", + "user_id": "…", + "favorited": true, + "favorite_count": 3 +} +``` + +可见性与查看云图一致:公开已审核照片,或本人/管理员可见的待审、隐藏照片。重复收藏为幂等;删除用户或云图时收藏记录级联删除。普通用户只能查自己的喜欢状态。 + ### 用户资料 | 方法 | 路径 | 说明 | @@ -269,11 +306,12 @@ uv run alembic upgrade head --sql - `tests/test_auth.py`:注册、邮箱确认、登录、刷新、登出、改密 - `tests/test_clouds.py`:上传、缩略图、画廊、地图、更新与删除 - `tests/test_collections.py`:图鉴解锁 +- `tests/test_favorites.py`:照片点赞收藏与取消、收藏列表 - `tests/test_cloud_types.py`:云类型查询 - `tests/test_profiles.py`:资料、用户云图、管理员 stats - `tests/test_admin.py`:统计、用户管理、审核与批量操作 -当前约 80 个用例。生产环境仍建议在真实 PostgreSQL 上再跑一遍迁移与关键路径。 +生产环境仍建议在真实 PostgreSQL 上再跑一遍迁移与关键路径。 ## 部署注意事项 diff --git a/alembic/versions/20260726_0002_cloud_favorites.py b/alembic/versions/20260726_0002_cloud_favorites.py new file mode 100644 index 0000000..e6d70ae --- /dev/null +++ b/alembic/versions/20260726_0002_cloud_favorites.py @@ -0,0 +1,50 @@ +"""Add cloud_favorites table for photo likes/favorites. + +Revision ID: 20260726_0002 +Revises: 20260718_0001 +Create Date: 2026-07-26 +""" + +from typing import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260726_0002" +down_revision: str | None = "20260718_0001" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "cloud_favorites", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("cloud_id", sa.Uuid(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["cloud_id"], ["clouds.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "cloud_id", name="uq_cloud_favorites_user_cloud"), + ) + op.create_index("ix_cloud_favorites_user_id", "cloud_favorites", ["user_id"]) + op.create_index("ix_cloud_favorites_cloud_id", "cloud_favorites", ["cloud_id"]) + op.create_index( + "ix_cloud_favorites_user_created", + "cloud_favorites", + ["user_id", sa.text("created_at DESC"), sa.text("id DESC")], + ) + + +def downgrade() -> None: + op.drop_index("ix_cloud_favorites_user_created", table_name="cloud_favorites") + op.drop_index("ix_cloud_favorites_cloud_id", table_name="cloud_favorites") + op.drop_index("ix_cloud_favorites_user_id", table_name="cloud_favorites") + op.drop_table("cloud_favorites") diff --git a/app/main.py b/app/main.py index e453fc7..43f9d5a 100644 --- a/app/main.py +++ b/app/main.py @@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from app.config import settings -from app.routers import admin, auth, cloud_types, clouds, collections, health, profiles +from app.routers import admin, auth, cloud_types, clouds, collections, favorites, health, profiles @asynccontextmanager @@ -36,6 +36,7 @@ for router in ( cloud_types.router, clouds.router, collections.router, + favorites.router, admin.router, ): app.include_router(router, prefix=settings.api_v1_prefix) diff --git a/app/models.py b/app/models.py index 7bcb39d..f7940b3 100644 --- a/app/models.py +++ b/app/models.py @@ -236,3 +236,33 @@ class UserCollection(Base): cloud_type: Mapped[CloudType] = relationship(lazy="raise") first_cloud: Mapped[Cloud | None] = relationship(lazy="raise") + + +class CloudFavorite(Base): + """用户对照片的点赞/收藏记录。""" + + __tablename__ = "cloud_favorites" + __table_args__ = ( + UniqueConstraint("user_id", "cloud_id", name="uq_cloud_favorites_user_cloud"), + Index("ix_cloud_favorites_user_id", "user_id"), + Index("ix_cloud_favorites_cloud_id", "cloud_id"), + Index( + "ix_cloud_favorites_user_created", + "user_id", + text("created_at DESC"), + text("id DESC"), + ), + ) + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column( + Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + cloud_id: Mapped[uuid.UUID] = mapped_column( + Uuid, ForeignKey("clouds.id", ondelete="CASCADE"), nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + cloud: Mapped[Cloud] = relationship(lazy="raise") diff --git a/app/routers/favorites.py b/app/routers/favorites.py new file mode 100644 index 0000000..fcfaffb --- /dev/null +++ b/app/routers/favorites.py @@ -0,0 +1,178 @@ +import math +import uuid + +from fastapi import APIRouter, HTTPException, Query, status +from sqlalchemy import delete, func, select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert +from sqlalchemy.orm import joinedload + +from app.deps import CurrentUser, DbSession +from app.models import Cloud, CloudFavorite, User +from app.schemas import CloudOut, FavoriteIn, FavoriteOut, PageOut +from app.serializers import cloud_out + + +router = APIRouter(tags=["点赞收藏"]) + + +def _cloud_options(): + return ( + joinedload(Cloud.user).joinedload(User.profile), + joinedload(Cloud.cloud_type), + ) + + +async def _get_visible_cloud( + db: DbSession, + cloud_id: uuid.UUID, + user: CurrentUser, +) -> Cloud: + result = await db.execute( + select(Cloud).options(*_cloud_options()).where(Cloud.id == cloud_id) + ) + cloud = result.scalar_one_or_none() + if not cloud: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在") + + can_manage = cloud.user_id == user.id or user.profile.role == "admin" + if not can_manage and (cloud.status != "approved" or cloud.is_hidden): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在") + return cloud + + +async def _favorite_count(db: DbSession, cloud_id: uuid.UUID) -> int: + return ( + await db.scalar( + select(func.count()) + .select_from(CloudFavorite) + .where(CloudFavorite.cloud_id == cloud_id) + ) + or 0 + ) + + +async def _is_favorited(db: DbSession, user_id: uuid.UUID, cloud_id: uuid.UUID) -> bool: + favorite_id = await db.scalar( + select(CloudFavorite.id).where( + CloudFavorite.user_id == user_id, + CloudFavorite.cloud_id == cloud_id, + ) + ) + return favorite_id is not None + + +async def _favorite_out( + db: DbSession, + *, + cloud_id: uuid.UUID, + user_id: uuid.UUID, + favorited: bool | None = None, +) -> FavoriteOut: + if favorited is None: + favorited = await _is_favorited(db, user_id, cloud_id) + return FavoriteOut( + cloud_id=cloud_id, + user_id=user_id, + favorited=favorited, + favorite_count=await _favorite_count(db, cloud_id), + ) + + +@router.get("/clouds/{cloud_id}/favorite", response_model=FavoriteOut) +async def get_cloud_favorite( + cloud_id: uuid.UUID, + user: CurrentUser, + db: DbSession, + user_id: uuid.UUID | None = Query( + None, + description="要查询的用户 ID;默认当前登录用户。仅本人或管理员可查他人。", + ), +) -> FavoriteOut: + """查询某用户对某张照片的点赞/收藏状态。""" + await _get_visible_cloud(db, cloud_id, user) + + target_user_id = user_id or user.id + if target_user_id != user.id and user.profile.role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="只能查询自己的喜欢状态", + ) + + return await _favorite_out(db, cloud_id=cloud_id, user_id=target_user_id) + + +@router.put("/clouds/{cloud_id}/favorite", response_model=FavoriteOut) +async def set_cloud_favorite( + cloud_id: uuid.UUID, + payload: FavoriteIn, + user: CurrentUser, + db: DbSession, +) -> FavoriteOut: + """点赞/收藏照片,或取消。""" + await _get_visible_cloud(db, cloud_id, user) + + if payload.favorited: + insert_factory = ( + pg_insert if db.bind and db.bind.dialect.name == "postgresql" else sqlite_insert + ) + await db.execute( + insert_factory(CloudFavorite) + .values(user_id=user.id, cloud_id=cloud_id) + .on_conflict_do_nothing( + index_elements=[CloudFavorite.user_id, CloudFavorite.cloud_id] + ) + ) + else: + await db.execute( + delete(CloudFavorite).where( + CloudFavorite.user_id == user.id, + CloudFavorite.cloud_id == cloud_id, + ) + ) + + await db.commit() + return await _favorite_out( + db, + cloud_id=cloud_id, + user_id=user.id, + favorited=payload.favorited, + ) + + +@router.get("/favorites/me", response_model=PageOut[CloudOut]) +async def list_my_favorites( + user: CurrentUser, + db: DbSession, + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=100), +) -> PageOut[CloudOut]: + """当前用户点赞收藏的照片列表(按收藏时间倒序)。""" + total = ( + await db.scalar( + select(func.count()) + .select_from(CloudFavorite) + .where(CloudFavorite.user_id == user.id) + ) + or 0 + ) + result = await db.execute( + select(Cloud) + .join(CloudFavorite, CloudFavorite.cloud_id == Cloud.id) + .options(*_cloud_options()) + .where(CloudFavorite.user_id == user.id) + .order_by(CloudFavorite.created_at.desc(), CloudFavorite.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + items = [ + cloud_out(item, include_private=item.user_id == user.id or user.profile.role == "admin") + for item in result.scalars().unique().all() + ] + return PageOut( + items=items, + page=page, + page_size=page_size, + total=total, + total_pages=max(1, math.ceil(total / page_size)) if total else 1, + ) diff --git a/app/schemas.py b/app/schemas.py index f650bbf..6046a1b 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -262,3 +262,16 @@ class ProfileStatsOut(BaseModel): cloud_count: int public_cloud_count: int collection_count: int + + +class FavoriteIn(BaseModel): + """设置或取消对照片的点赞收藏。""" + + favorited: bool + + +class FavoriteOut(BaseModel): + cloud_id: uuid.UUID + user_id: uuid.UUID + favorited: bool + favorite_count: int diff --git a/tests/test_favorites.py b/tests/test_favorites.py new file mode 100644 index 0000000..6ddb79b --- /dev/null +++ b/tests/test_favorites.py @@ -0,0 +1,219 @@ +import uuid + +from fastapi.testclient import TestClient + +from tests.conftest import ( + admin_token, + approve_cloud, + bearer, + register_and_confirm, + upload_cloud, +) + + +def _set_favorite(client: TestClient, token: str, cloud_id: str, favorited: bool): + return client.put( + f"/api/v1/clouds/{cloud_id}/favorite", + headers=bearer(token), + json={"favorited": favorited}, + ) + + +def _get_favorite(client: TestClient, token: str, cloud_id: str, **params): + return client.get( + f"/api/v1/clouds/{cloud_id}/favorite", + headers=bearer(token), + params=params or None, + ) + + +def test_favorite_and_unfavorite_public_cloud(client: TestClient, monkeypatch) -> None: + owner = register_and_confirm( + client, + monkeypatch, + f"fav-owner-{uuid.uuid4().hex[:8]}@example.com", + "favowner12", + "收藏主人", + ) + fan = register_and_confirm( + client, + monkeypatch, + f"fav-fan-{uuid.uuid4().hex[:8]}@example.com", + "favfan1234", + "点赞粉丝", + ) + upload = upload_cloud(client, owner["token"], cloud_type_id="1") + cloud_id = upload.json()["cloud"]["id"] + approve_cloud(client, admin_token(client), cloud_id) + + r = _get_favorite(client, fan["token"], cloud_id) + assert r.status_code == 200 + assert r.json()["favorited"] is False + assert r.json()["favorite_count"] == 0 + assert r.json()["user_id"] == fan["user_id"] + + r = _set_favorite(client, fan["token"], cloud_id, True) + assert r.status_code == 200 + body = r.json() + assert body["cloud_id"] == cloud_id + assert body["user_id"] == fan["user_id"] + assert body["favorited"] is True + assert body["favorite_count"] == 1 + + r = _get_favorite(client, fan["token"], cloud_id) + assert r.status_code == 200 + assert r.json()["favorited"] is True + assert r.json()["favorite_count"] == 1 + + # idempotent re-favorite + r = _set_favorite(client, fan["token"], cloud_id, True) + assert r.status_code == 200 + assert r.json()["favorited"] is True + assert r.json()["favorite_count"] == 1 + + r = _set_favorite(client, fan["token"], cloud_id, False) + assert r.status_code == 200 + assert r.json()["favorited"] is False + assert r.json()["favorite_count"] == 0 + + r = _get_favorite(client, fan["token"], cloud_id) + assert r.status_code == 200 + assert r.json()["favorited"] is False + + +def test_get_favorite_for_other_user_forbidden(client: TestClient, monkeypatch) -> None: + owner = register_and_confirm( + client, + monkeypatch, + f"other-owner-{uuid.uuid4().hex[:8]}@example.com", + "otherowner", + "他人主人", + ) + fan = register_and_confirm( + client, + monkeypatch, + f"other-fan-{uuid.uuid4().hex[:8]}@example.com", + "otherfan12", + "他人粉丝", + ) + upload = upload_cloud(client, owner["token"], cloud_type_id="1") + cloud_id = upload.json()["cloud"]["id"] + approve_cloud(client, admin_token(client), cloud_id) + assert _set_favorite(client, fan["token"], cloud_id, True).status_code == 200 + + # 普通用户不能查别人的喜欢状态 + r = _get_favorite(client, owner["token"], cloud_id, user_id=fan["user_id"]) + assert r.status_code == 403 + + # 管理员可以 + r = _get_favorite(client, admin_token(client), cloud_id, user_id=fan["user_id"]) + assert r.status_code == 200 + assert r.json()["user_id"] == fan["user_id"] + assert r.json()["favorited"] is True + assert r.json()["favorite_count"] == 1 + + +def test_get_favorite_unauthenticated(client: TestClient) -> None: + r = client.get(f"/api/v1/clouds/{uuid.uuid4()}/favorite") + assert r.status_code == 401 + + +def test_favorite_unauthenticated(client: TestClient) -> None: + r = client.put( + f"/api/v1/clouds/{uuid.uuid4()}/favorite", + json={"favorited": True}, + ) + assert r.status_code == 401 + + +def test_cannot_favorite_pending_cloud_of_others(client: TestClient, monkeypatch) -> None: + owner = register_and_confirm( + client, + monkeypatch, + f"pending-owner-{uuid.uuid4().hex[:8]}@example.com", + "pendingown", + "待审主人", + ) + fan = register_and_confirm( + client, + monkeypatch, + f"pending-fan-{uuid.uuid4().hex[:8]}@example.com", + "pendingfan", + "待审粉丝", + ) + upload = upload_cloud(client, owner["token"], cloud_type_id="2") + cloud_id = upload.json()["cloud"]["id"] + + r = _set_favorite(client, fan["token"], cloud_id, True) + assert r.status_code == 404 + + +def test_owner_can_favorite_own_pending_cloud(client: TestClient, monkeypatch) -> None: + owner = register_and_confirm( + client, + monkeypatch, + f"own-fav-{uuid.uuid4().hex[:8]}@example.com", + "ownfavpass", + "自赞用户", + ) + upload = upload_cloud(client, owner["token"], cloud_type_id="3") + cloud_id = upload.json()["cloud"]["id"] + + r = _set_favorite(client, owner["token"], cloud_id, True) + assert r.status_code == 200 + assert r.json()["favorited"] is True + assert r.json()["favorite_count"] == 1 + + +def test_list_my_favorites(client: TestClient, monkeypatch) -> None: + owner = register_and_confirm( + client, + monkeypatch, + f"list-owner-{uuid.uuid4().hex[:8]}@example.com", + "listowner1", + "列表主人", + ) + fan = register_and_confirm( + client, + monkeypatch, + f"list-fan-{uuid.uuid4().hex[:8]}@example.com", + "listfan123", + "列表粉丝", + ) + ids = [] + for type_id in ("1", "2"): + upload = upload_cloud(client, owner["token"], cloud_type_id=type_id) + cloud_id = upload.json()["cloud"]["id"] + approve_cloud(client, admin_token(client), cloud_id) + ids.append(cloud_id) + assert _set_favorite(client, fan["token"], cloud_id, True).status_code == 200 + + r = client.get("/api/v1/favorites/me", headers=bearer(fan["token"])) + assert r.status_code == 200 + body = r.json() + assert body["total"] == 2 + returned_ids = [item["id"] for item in body["items"]] + assert set(returned_ids) == set(ids) + + assert _set_favorite(client, fan["token"], ids[0], False).status_code == 200 + r = client.get("/api/v1/favorites/me", headers=bearer(fan["token"])) + assert r.status_code == 200 + assert r.json()["total"] == 1 + assert r.json()["items"][0]["id"] == ids[1] + + +def test_favorite_missing_cloud(client: TestClient, monkeypatch) -> None: + user = register_and_confirm( + client, + monkeypatch, + f"missing-{uuid.uuid4().hex[:8]}@example.com", + "missing123", + "缺图用户", + ) + r = _set_favorite(client, user["token"], str(uuid.uuid4()), True) + assert r.status_code == 404 + + +def test_list_favorites_unauthenticated(client: TestClient) -> None: + r = client.get("/api/v1/favorites/me") + assert r.status_code == 401