新增点赞收藏功能:cloud_favorites 模型、查询/设置点赞状态及收藏列表接口

This commit is contained in:
2026-07-26 02:10:16 +08:00
parent b95400df2b
commit 49df3f067a
7 changed files with 531 additions and 2 deletions
+39 -1
View File
@@ -29,6 +29,7 @@ app/
clouds.py # 云图 clouds.py # 云图
cloud_types.py # 云类型 cloud_types.py # 云类型
collections.py # 图鉴 collections.py # 图鉴
favorites.py # 照片点赞收藏
profiles.py # 用户资料 profiles.py # 用户资料
admin.py # 管理后台 admin.py # 管理后台
health.py # 健康检查 health.py # 健康检查
@@ -159,6 +160,42 @@ fetch(url, { credentials: 'include' })
seed 会写入 10 种标准云类型(积云、层云、卷云等),含中英文名、云族与稀有度。 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=<uuid>
```
设置/取消请求体:
```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_auth.py`:注册、邮箱确认、登录、刷新、登出、改密
- `tests/test_clouds.py`:上传、缩略图、画廊、地图、更新与删除 - `tests/test_clouds.py`:上传、缩略图、画廊、地图、更新与删除
- `tests/test_collections.py`:图鉴解锁 - `tests/test_collections.py`:图鉴解锁
- `tests/test_favorites.py`:照片点赞收藏与取消、收藏列表
- `tests/test_cloud_types.py`:云类型查询 - `tests/test_cloud_types.py`:云类型查询
- `tests/test_profiles.py`:资料、用户云图、管理员 stats - `tests/test_profiles.py`:资料、用户云图、管理员 stats
- `tests/test_admin.py`:统计、用户管理、审核与批量操作 - `tests/test_admin.py`:统计、用户管理、审核与批量操作
当前约 80 个用例。生产环境仍建议在真实 PostgreSQL 上再跑一遍迁移与关键路径。 生产环境仍建议在真实 PostgreSQL 上再跑一遍迁移与关键路径。
## 部署注意事项 ## 部署注意事项
@@ -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")
+2 -1
View File
@@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app.config import settings 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 @asynccontextmanager
@@ -36,6 +36,7 @@ for router in (
cloud_types.router, cloud_types.router,
clouds.router, clouds.router,
collections.router, collections.router,
favorites.router,
admin.router, admin.router,
): ):
app.include_router(router, prefix=settings.api_v1_prefix) app.include_router(router, prefix=settings.api_v1_prefix)
+30
View File
@@ -236,3 +236,33 @@ class UserCollection(Base):
cloud_type: Mapped[CloudType] = relationship(lazy="raise") cloud_type: Mapped[CloudType] = relationship(lazy="raise")
first_cloud: Mapped[Cloud | None] = 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")
+178
View File
@@ -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,
)
+13
View File
@@ -262,3 +262,16 @@ class ProfileStatsOut(BaseModel):
cloud_count: int cloud_count: int
public_cloud_count: int public_cloud_count: int
collection_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
+219
View File
@@ -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