From e75833ee4b6208b50636447a2466bfdcaff5f19d Mon Sep 17 00:00:00 2001 From: Mplan Date: Tue, 28 Jul 2026 23:48:57 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E8=A1=A8=E7=BB=93=E6=9E=84?= =?UTF-8?q?=EF=BC=9A=E5=90=88=E5=B9=B6=20profiles=E2=86=92users=EF=BC=8C?= =?UTF-8?q?=E5=B1=95=E5=B9=B3=20API=20=E5=93=8D=E5=BA=94=EF=BC=8C=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E8=AE=A1=E6=95=B0=E5=99=A8=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - models: 删除 Profile 模型,字段合并到 User;User 新增云朵/公开/收藏计数器;Cloud 新增 favorite_count - schemas/serializers: 展平 UserOut/AuthOut/MeOut/AdminUserOut,移除嵌套 profile - deps: user.profile.role→user.role,移除 selectinload profile - auth/register: 注册时直接设置 user 字段,不再创建 Profile - clouds: 创建/删除云朵时同步 user.cloud_count 计数器,状态变化时同步 public_cloud_count - favorites: 点赞/取消时同步 cloud.favorite_count 计数器 - admin: 审批/隐藏/批量删时同步 public_cloud_count 计数器 - profiles/stats: 用计数器替代实时 COUNT 查询 - alembic: 新增 migration 合并 profiles 到 users,初始化 counters --- ...20260728_0003_merge_profiles_into_users.py | 95 ++++++++++ app/deps.py | 5 +- app/models.py | 63 ++----- app/routers/admin.py | 102 ++++++++-- app/routers/auth.py | 95 +++++----- app/routers/cloud_types.py | 2 +- app/routers/clouds.py | 84 +++++++-- app/routers/favorites.py | 48 ++--- app/routers/profiles.py | 174 +++++++++++------- app/schemas.py | 68 +++---- app/seed.py | 11 +- app/serializers.py | 47 ++--- tests/test_admin.py | 8 +- tests/test_auth.py | 2 +- 14 files changed, 519 insertions(+), 285 deletions(-) create mode 100644 alembic/versions/20260728_0003_merge_profiles_into_users.py diff --git a/alembic/versions/20260728_0003_merge_profiles_into_users.py b/alembic/versions/20260728_0003_merge_profiles_into_users.py new file mode 100644 index 0000000..16fe854 --- /dev/null +++ b/alembic/versions/20260728_0003_merge_profiles_into_users.py @@ -0,0 +1,95 @@ +"""Merge profiles table into users, add counter columns. + +Revision ID: 20260728_0003 +Revises: 20260726_0002 +Create Date: 2026-07-28 +""" + +from typing import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260728_0003" +down_revision: str | None = "20260726_0002" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("username", sa.Text(), nullable=True)) + op.add_column("users", sa.Column("avatar_path", sa.Text(), nullable=True)) + op.add_column("users", sa.Column("role", sa.Text(), server_default="user", nullable=True)) + op.add_column("users", sa.Column("is_disabled", sa.Boolean(), server_default=sa.text("false"), nullable=True)) + op.add_column("users", sa.Column("cloud_count", sa.Integer(), server_default="0", nullable=True)) + op.add_column("users", sa.Column("public_cloud_count", sa.Integer(), server_default="0", nullable=True)) + op.add_column("users", sa.Column("collection_count", sa.Integer(), server_default="0", nullable=True)) + + op.execute(""" + UPDATE users SET + username = p.username, + avatar_path = p.avatar_path, + role = p.role, + is_disabled = p.is_disabled + FROM profiles p + WHERE users.id = p.id + """) + + op.alter_column("users", "username", nullable=False) + op.alter_column("users", "role", nullable=False) + op.alter_column("users", "is_disabled", nullable=False) + op.alter_column("users", "cloud_count", nullable=False) + op.alter_column("users", "public_cloud_count", nullable=False) + op.alter_column("users", "collection_count", nullable=False) + + op.create_unique_constraint("uq_users_username", "users", ["username"]) + op.create_check_constraint("ck_users_username_length", "users", "length(trim(username)) >= 2") + op.create_check_constraint("ck_users_role", "users", "role IN ('user', 'admin')") + + op.drop_table("profiles") + + op.add_column("clouds", sa.Column("favorite_count", sa.Integer(), server_default="0", nullable=True)) + op.execute(""" + UPDATE clouds SET favorite_count = ( + SELECT COUNT(*) FROM cloud_favorites WHERE cloud_favorites.cloud_id = clouds.id + ) + """) + op.alter_column("clouds", "favorite_count", nullable=False) + + +def downgrade() -> None: + op.create_table( + "profiles", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("username", sa.Text(), nullable=False), + sa.Column("avatar_path", sa.Text(), nullable=True), + sa.Column("role", sa.Text(), server_default="user", nullable=False), + sa.Column("is_disabled", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.CheckConstraint("length(trim(username)) >= 2", name="ck_profiles_username_length"), + sa.CheckConstraint("role IN ('user', 'admin')", name="ck_profiles_role"), + sa.ForeignKeyConstraint(["id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("username"), + ) + + op.execute(""" + INSERT INTO profiles (id, username, avatar_path, role, is_disabled, created_at, updated_at) + SELECT id, username, avatar_path, role, is_disabled, created_at, created_at + FROM users + """) + + op.drop_constraint("ck_users_role", "users", type_="check") + op.drop_constraint("ck_users_username_length", "users", type_="check") + op.drop_constraint("uq_users_username", "users", type_="unique") + op.drop_column("users", "collection_count") + op.drop_column("users", "public_cloud_count") + op.drop_column("users", "cloud_count") + op.drop_column("users", "is_disabled") + op.drop_column("users", "role") + op.drop_column("users", "avatar_path") + op.drop_column("users", "username") + + op.drop_column("clouds", "favorite_count") diff --git a/app/deps.py b/app/deps.py index 4e0fc79..a3c8637 100644 --- a/app/deps.py +++ b/app/deps.py @@ -34,7 +34,6 @@ async def _resolve_user( (RefreshSession.id == payload.session_id) & (RefreshSession.user_id == User.id), ) - .options(selectinload(User.profile)) .where( User.id == payload.user_id, RefreshSession.revoked_at.is_(None), @@ -44,7 +43,7 @@ async def _resolve_user( user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录状态已失效") - if user.profile.is_disabled: + if user.is_disabled: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用") return user @@ -68,7 +67,7 @@ async def get_optional_user( async def get_admin_user(user: Annotated[User, Depends(get_current_user)]) -> User: - if user.profile.role != "admin": + if user.role != "admin": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限") return user diff --git a/app/models.py b/app/models.py index f7940b3..2271d65 100644 --- a/app/models.py +++ b/app/models.py @@ -8,6 +8,7 @@ from sqlalchemy import ( DateTime, ForeignKey, Index, + Integer, Numeric, SmallInteger, Text, @@ -32,42 +33,28 @@ class TimestampMixin: class User(Base, TimestampMixin): __tablename__ = "users" + __table_args__ = ( + CheckConstraint("length(trim(username)) >= 2", name="ck_users_username_length"), + CheckConstraint("role IN ('user', 'admin')", name="ck_users_role"), + ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) email: Mapped[str] = mapped_column(Text, unique=True, nullable=False) password_hash: Mapped[str] = mapped_column(Text, nullable=False) email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - profile: Mapped["Profile"] = relationship( - back_populates="user", cascade="all, delete-orphan", uselist=False, lazy="raise" - ) - clouds: Mapped[list["Cloud"]] = relationship(back_populates="user", lazy="raise") - - -class Profile(Base): - __tablename__ = "profiles" - __table_args__ = ( - CheckConstraint("length(trim(username)) >= 2", name="ck_profiles_username_length"), - CheckConstraint("role IN ('user', 'admin')", name="ck_profiles_role"), - ) - - id: Mapped[uuid.UUID] = mapped_column( - Uuid, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True - ) username: Mapped[str] = mapped_column(Text, unique=True, nullable=False) avatar_path: Mapped[str | None] = mapped_column(Text) role: Mapped[str] = mapped_column(Text, nullable=False, default="user", server_default="user") is_disabled: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, server_default=text("false") ) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), nullable=False - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False - ) - user: Mapped[User] = relationship(back_populates="profile", lazy="raise") + cloud_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + public_cloud_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + collection_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + + clouds: Mapped[list["Cloud"]] = relationship(back_populates="user", lazy="raise") class RefreshSession(Base): @@ -163,28 +150,7 @@ class Cloud(Base, TimestampMixin): ), Index("ix_clouds_user_id", "user_id"), Index("ix_clouds_cloud_type_id", "cloud_type_id"), - Index("ix_clouds_user_captured", "user_id", text("captured_at DESC"), text("created_at DESC")), Index("ix_clouds_status_created", "status", text("created_at DESC")), - Index( - "ix_clouds_public_gallery", - text("created_at DESC"), - text("id DESC"), - postgresql_where=text("status = 'approved' AND is_hidden = false"), - ), - Index( - "ix_clouds_public_map", - "captured_at", - postgresql_where=text( - "status = 'approved' AND is_hidden = false " - "AND latitude IS NOT NULL AND longitude IS NOT NULL" - ), - ), - Index( - "ix_clouds_public_type", - "cloud_type_id", - text("captured_at DESC"), - postgresql_where=text("status = 'approved' AND is_hidden = false"), - ), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) @@ -206,6 +172,7 @@ class Cloud(Base, TimestampMixin): is_hidden: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, server_default=text("false") ) + favorite_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") user: Mapped[User] = relationship(back_populates="clouds", lazy="raise") cloud_type: Mapped[CloudType | None] = relationship(lazy="raise") @@ -239,19 +206,11 @@ class UserCollection(Base): 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) diff --git a/app/routers/admin.py b/app/routers/admin.py index f130793..3e3b69c 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -7,10 +7,10 @@ from zoneinfo import ZoneInfo from fastapi import APIRouter, HTTPException, Query, status from sqlalchemy import case, func, select, update from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import joinedload, selectinload +from sqlalchemy.orm import joinedload from app.deps import AdminUser, DbSession -from app.models import Cloud, Profile, User +from app.models import Cloud, User from app.schemas import ( AdminCloudStatusIn, AdminCloudVisibilityIn, @@ -25,7 +25,7 @@ from app.schemas import ( PageOut, ) from app.security import hash_password, utc_now -from app.serializers import cloud_out, profile_out, user_out +from app.serializers import cloud_out, user_out from app.services.storage import delete_files @@ -71,12 +71,11 @@ async def list_users( total = await db.scalar(select(func.count()).select_from(User)) or 0 result = await db.execute( select(User) - .options(selectinload(User.profile)) .order_by(User.created_at.desc(), User.id.desc()) .offset((page - 1) * page_size) .limit(page_size) ) - items = [AdminUserOut(user=user_out(user), profile=profile_out(user.profile)) for user in result.scalars()] + items = [AdminUserOut(user=user_out(user)) for user in result.scalars()] return PageOut( items=items, page=page, @@ -97,8 +96,9 @@ async def create_user( email=email, password_hash=hash_password(payload.password), email_verified_at=utc_now(), + username=payload.username, + role=payload.role, ) - user.profile = Profile(username=payload.username, role=payload.role) db.add(user) try: await db.commit() @@ -108,8 +108,8 @@ async def create_user( if "email" in message: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该邮箱已被注册") from exc raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="这个昵称已经被使用") from exc - await db.refresh(user, ["profile"]) - return AdminUserOut(user=user_out(user), profile=profile_out(user.profile)) + await db.refresh(user) + return AdminUserOut(user=user_out(user)) @router.patch("/users/{user_id}", response_model=AdminUserOut) @@ -119,10 +119,7 @@ async def update_user( admin: AdminUser, db: DbSession, ) -> AdminUserOut: - result = await db.execute( - select(User).options(selectinload(User.profile)).where(User.id == user_id) - ) - user = result.scalar_one_or_none() + user = await db.get(User, user_id) if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") if user.id == admin.id and ( @@ -130,11 +127,11 @@ async def update_user( ): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能禁用自己或移除自己的管理员权限") if payload.role is not None: - user.profile.role = payload.role + user.role = payload.role if payload.is_disabled is not None: - user.profile.is_disabled = payload.is_disabled + user.is_disabled = payload.is_disabled await db.commit() - return AdminUserOut(user=user_out(user), profile=profile_out(user.profile)) + return AdminUserOut(user=user_out(user)) @router.get("/clouds", response_model=PageOut[CloudOut]) @@ -154,7 +151,7 @@ async def list_admin_clouds( total = await db.scalar(select(func.count()).select_from(Cloud).where(*filters)) or 0 result = await db.execute( select(Cloud) - .options(joinedload(Cloud.user).joinedload(User.profile), joinedload(Cloud.cloud_type)) + .options(joinedload(Cloud.user), joinedload(Cloud.cloud_type)) .where(*filters) .order_by(Cloud.created_at.desc(), Cloud.id.desc()) .offset((page - 1) * page_size) @@ -182,6 +179,37 @@ async def update_cloud_status( db: DbSession, ) -> BatchUpdateOut: await _require_all_cloud_ids(db, payload.ids) + + if payload.status == "approved": + await db.execute( + update(User) + .where(User.id.in_( + select(Cloud.user_id).where( + Cloud.id.in_(payload.ids), + Cloud.status != "approved", + Cloud.is_hidden.is_(False), + ) + )) + .values(public_cloud_count=User.public_cloud_count + 1) + ) + else: + await db.execute( + update(User) + .where(User.id.in_( + select(Cloud.user_id).where( + Cloud.id.in_(payload.ids), + Cloud.status == "approved", + Cloud.is_hidden.is_(False), + ) + )) + .values( + public_cloud_count=case( + (User.public_cloud_count > 0, User.public_cloud_count - 1), + else_=0, + ) + ) + ) + await db.execute( update(Cloud) .where(Cloud.id.in_(payload.ids)) @@ -198,6 +226,37 @@ async def update_cloud_visibility( db: DbSession, ) -> BatchUpdateOut: await _require_all_cloud_ids(db, payload.ids) + + if payload.is_hidden: + await db.execute( + update(User) + .where(User.id.in_( + select(Cloud.user_id).where( + Cloud.id.in_(payload.ids), + Cloud.status == "approved", + Cloud.is_hidden.is_(False), + ) + )) + .values( + public_cloud_count=case( + (User.public_cloud_count > 0, User.public_cloud_count - 1), + else_=0, + ) + ) + ) + else: + await db.execute( + update(User) + .where(User.id.in_( + select(Cloud.user_id).where( + Cloud.id.in_(payload.ids), + Cloud.status == "approved", + Cloud.is_hidden.is_(True), + ) + )) + .values(public_cloud_count=User.public_cloud_count + 1) + ) + await db.execute( update(Cloud) .where(Cloud.id.in_(payload.ids)) @@ -218,8 +277,19 @@ async def delete_admin_clouds( if len(clouds) != len(payload.ids): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部分图片不存在") paths = [path for cloud in clouds for path in (cloud.image_path, cloud.thumbnail_path)] + + user_ids: dict[uuid.UUID, int] = {} for cloud in clouds: + user_ids[cloud.user_id] = user_ids.get(cloud.user_id, 0) + 1 await db.delete(cloud) + + for uid, count in user_ids.items(): + await db.execute( + update(User) + .where(User.id == uid) + .values(cloud_count=User.cloud_count - count) + ) + await db.commit() await delete_files(paths) return DeleteResultOut(deleted=len(clouds)) diff --git a/app/routers/auth.py b/app/routers/auth.py index 90ab500..fa98cf6 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload from app.config import settings from app.deps import CurrentUser, DbSession -from app.models import EmailToken, Profile, RefreshSession, User +from app.models import EmailToken, RefreshSession, User from app.schemas import ( AuthOut, ChangePasswordIn, @@ -30,7 +30,7 @@ from app.security import ( utc_now, verify_password, ) -from app.serializers import profile_out, user_out +from app.serializers import user_out from app.services.email import send_confirmation_email, send_password_reset_email @@ -72,28 +72,6 @@ def _clear_refresh_cookie(response: Response) -> None: ) -def _auth_response(user: User, session: RefreshSession) -> AuthOut: - return AuthOut( - access_token=create_access_token(user.id, session.id), - expires_in=settings.access_token_expire_minutes * 60, - user=user_out(user), - profile=profile_out(user.profile), - ) - - -def _new_session(user: User, request: Request) -> tuple[RefreshSession, str]: - token = create_random_token() - user_agent, ip_address = _client_metadata(request) - session = RefreshSession( - user_id=user.id, - token_hash=hash_token(token), - expires_at=refresh_expires_at(), - user_agent=user_agent, - ip_address=ip_address, - ) - return session, token - - async def _create_email_token(db: DbSession, user: User, purpose: str, expires_delta: timedelta) -> str: now = utc_now() await db.execute( @@ -120,8 +98,7 @@ async def _create_email_token(db: DbSession, user: User, purpose: str, expires_d @router.post("/register", response_model=MessageOut, status_code=status.HTTP_201_CREATED) async def register(payload: RegisterIn, db: DbSession) -> MessageOut: email = str(payload.email).strip().lower() - user = User(email=email, password_hash=hash_password(payload.password)) - user.profile = Profile(username=payload.username) + user = User(email=email, password_hash=hash_password(payload.password), username=payload.username) db.add(user) try: await db.flush() @@ -194,9 +171,7 @@ async def confirm_email(payload: TokenIn, db: DbSession) -> MessageOut: @router.post("/login", response_model=AuthOut) async def login(payload: LoginIn, request: Request, response: Response, db: DbSession) -> AuthOut: email = str(payload.email).strip().lower() - result = await db.execute( - select(User).options(selectinload(User.profile)).where(User.email == email) - ) + result = await db.execute(select(User).where(User.email == email)) user = result.scalar_one_or_none() encoded_hash = user.password_hash if user else DUMMY_PASSWORD_HASH password_valid = verify_password(payload.password, encoded_hash) @@ -204,14 +179,26 @@ async def login(payload: LoginIn, request: Request, response: Response, db: DbSe raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码错误") if user.email_verified_at is None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="邮箱尚未确认") - if user.profile.is_disabled: + if user.is_disabled: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用") - session, refresh_token = _new_session(user, request) + token = create_random_token() + user_agent, ip_address = _client_metadata(request) + session = RefreshSession( + user_id=user.id, + token_hash=hash_token(token), + expires_at=refresh_expires_at(), + user_agent=user_agent, + ip_address=ip_address, + ) db.add(session) await db.commit() - _set_refresh_cookie(response, refresh_token) - return _auth_response(user, session) + _set_refresh_cookie(response, token) + return AuthOut( + access_token=create_access_token(user.id, session.id), + expires_in=settings.access_token_expire_minutes * 60, + user=user_out(user), + ) @router.post("/refresh", response_model=AuthOut) @@ -222,7 +209,7 @@ async def refresh(request: Request, response: Response, db: DbSession) -> AuthOu now = utc_now() result = await db.execute( select(RefreshSession) - .options(selectinload(RefreshSession.user).selectinload(User.profile)) + .options(selectinload(RefreshSession.user)) .where(RefreshSession.token_hash == hash_token(raw_token)) .with_for_update() ) @@ -235,20 +222,32 @@ async def refresh(request: Request, response: Response, db: DbSession) -> AuthOu _clear_refresh_cookie(response) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="刷新令牌无效或已过期") user = old_session.user - if user.profile.is_disabled: + if user.is_disabled: old_session.revoked_at = now await db.commit() _clear_refresh_cookie(response) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用") - new_session, new_token = _new_session(user, request) + token = create_random_token() + user_agent, ip_address = _client_metadata(request) + new_session = RefreshSession( + user_id=user.id, + token_hash=hash_token(token), + expires_at=refresh_expires_at(), + user_agent=user_agent, + ip_address=ip_address, + ) db.add(new_session) await db.flush() old_session.revoked_at = now old_session.replaced_by_id = new_session.id await db.commit() - _set_refresh_cookie(response, new_token) - return _auth_response(user, new_session) + _set_refresh_cookie(response, token) + return AuthOut( + access_token=create_access_token(user.id, new_session.id), + expires_in=settings.access_token_expire_minutes * 60, + user=user_out(user), + ) @router.post("/logout", response_model=MessageOut) @@ -268,7 +267,7 @@ async def logout(request: Request, response: Response, db: DbSession) -> Message @router.get("/me", response_model=MeOut) async def me(user: CurrentUser) -> MeOut: - return MeOut(user=user_out(user), profile=profile_out(user.profile)) + return MeOut(user=user_out(user)) @router.post("/forgot-password", response_model=MessageOut, status_code=status.HTTP_202_ACCEPTED) @@ -336,8 +335,20 @@ async def change_password( .where(RefreshSession.user_id == user.id, RefreshSession.revoked_at.is_(None)) .values(revoked_at=now) ) - new_session, refresh_token = _new_session(user, request) + token = create_random_token() + user_agent, ip_address = _client_metadata(request) + new_session = RefreshSession( + user_id=user.id, + token_hash=hash_token(token), + expires_at=refresh_expires_at(), + user_agent=user_agent, + ip_address=ip_address, + ) db.add(new_session) await db.commit() - _set_refresh_cookie(response, refresh_token) - return _auth_response(user, new_session) + _set_refresh_cookie(response, token) + return AuthOut( + access_token=create_access_token(user.id, new_session.id), + expires_in=settings.access_token_expire_minutes * 60, + user=user_out(user), + ) diff --git a/app/routers/cloud_types.py b/app/routers/cloud_types.py index 94090c5..17b540e 100644 --- a/app/routers/cloud_types.py +++ b/app/routers/cloud_types.py @@ -44,7 +44,7 @@ async def get_cloud_type_clouds( total = await db.scalar(select(func.count()).select_from(Cloud).where(*filters)) or 0 result = await db.execute( select(Cloud) - .options(joinedload(Cloud.user).joinedload(User.profile), joinedload(Cloud.cloud_type)) + .options(joinedload(Cloud.user), joinedload(Cloud.cloud_type)) .where(*filters) .order_by(Cloud.captured_at.desc().nulls_last(), Cloud.created_at.desc(), Cloud.id.desc()) .offset((page - 1) * page_size) diff --git a/app/routers/clouds.py b/app/routers/clouds.py index a48e0d6..e271ab4 100644 --- a/app/routers/clouds.py +++ b/app/routers/clouds.py @@ -5,13 +5,13 @@ from decimal import Decimal, ROUND_HALF_UP from typing import Annotated, Literal from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, status -from sqlalchemy import func, or_, select +from sqlalchemy import func, or_, select, update 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, OptionalUser -from app.models import Cloud, CloudType, Profile, User, UserCollection +from app.models import Cloud, CloudFavorite, CloudType, User, UserCollection from app.schemas import ( BadgeOut, BatchIdsIn, @@ -30,7 +30,7 @@ router = APIRouter(prefix="/clouds", tags=["云图"]) def _cloud_options(): return ( - joinedload(Cloud.user).joinedload(User.profile), + joinedload(Cloud.user), joinedload(Cloud.cloud_type), ) @@ -93,9 +93,9 @@ async def list_gallery_clouds( username = term[1:].strip() if not username: return PageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1) - data_query = data_query.join(Cloud.user).join(User.profile) - count_query = count_query.join(Cloud.user).join(User.profile) - filters.append(Profile.username.ilike(f"%{username}%")) + data_query = data_query.join(Cloud.user) + count_query = count_query.join(Cloud.user) + filters.append(User.username.ilike(f"%{username}%")) elif term: data_query = data_query.outerjoin(Cloud.cloud_type) count_query = count_query.outerjoin(Cloud.cloud_type) @@ -158,10 +158,22 @@ async def get_cloud(cloud_id: uuid.UUID, db: DbSession, viewer: OptionalUser) -> cloud = await _get_cloud(db, cloud_id) if not cloud: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在") - can_manage = bool(viewer and (viewer.id == cloud.user_id or viewer.profile.role == "admin")) + can_manage = bool(viewer and (viewer.id == cloud.user_id or viewer.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_out(cloud, include_private=can_manage) + + favorite_count = cloud.favorite_count + is_favorited = False + if viewer: + fav_id = await db.scalar( + select(CloudFavorite.id).where( + CloudFavorite.user_id == viewer.id, + CloudFavorite.cloud_id == cloud_id, + ) + ) + is_favorited = fav_id is not None + + return cloud_out(cloud, include_private=can_manage, favorite_count=favorite_count, is_favorited=is_favorited) @router.post("", response_model=CloudCreateOut, status_code=status.HTTP_201_CREATED) @@ -207,6 +219,13 @@ async def create_cloud( unlocked_badge: BadgeOut | None = None try: await db.flush() + + await db.execute( + update(User) + .where(User.id == user.id) + .values(cloud_count=User.cloud_count + 1) + ) + if cloud_type: insert_factory = pg_insert if db.bind and db.bind.dialect.name == "postgresql" else sqlite_insert insert_result = await db.execute( @@ -223,6 +242,11 @@ async def create_cloud( ) unlocked_at = insert_result.scalar_one_or_none() if unlocked_at: + await db.execute( + update(User) + .where(User.id == user.id) + .values(collection_count=User.collection_count + 1) + ) unlocked_badge = BadgeOut( cloud_type_id=cloud_type.id, cloud_name=cloud_type.name, @@ -252,9 +276,10 @@ async def update_cloud( cloud = await _get_cloud(db, cloud_id) if not cloud: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在") - if cloud.user_id != user.id and user.profile.role != "admin": + if cloud.user_id != user.id and user.role != "admin": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改其他用户的图片") + previous_public = cloud.status == "approved" and not cloud.is_hidden changes = payload.model_dump(exclude_unset=True) final_type_id = changes.get("cloud_type_id", cloud.cloud_type_id) final_custom = _normalize_text(changes.get("custom_cloud_type", cloud.custom_cloud_type)) @@ -284,8 +309,18 @@ async def update_cloud( cloud.captured_at = _normalize_datetime(changes["captured_at"]) if "is_hidden" in changes: cloud.is_hidden = changes["is_hidden"] - await db.commit() + await db.flush() + new_public = cloud.status == "approved" and not cloud.is_hidden + if previous_public != new_public: + delta = 1 if new_public else -1 + await db.execute( + update(User) + .where(User.id == cloud.user_id) + .values(public_cloud_count=User.public_cloud_count + delta) + ) + + await db.commit() updated = await _get_cloud(db, cloud.id) if not updated: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在") @@ -297,10 +332,25 @@ async def delete_cloud(cloud_id: uuid.UUID, db: DbSession, user: CurrentUser) -> cloud = await _get_cloud(db, cloud_id) if not cloud: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在") - if cloud.user_id != user.id and user.profile.role != "admin": + 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] + + was_public = cloud.status == "approved" and not cloud.is_hidden await db.delete(cloud) + + await db.execute( + update(User) + .where(User.id == cloud.user_id) + .values(cloud_count=User.cloud_count - 1) + ) + if was_public: + await db.execute( + update(User) + .where(User.id == cloud.user_id) + .values(public_cloud_count=User.public_cloud_count - 1) + ) + await db.commit() await delete_files(paths) return DeleteResultOut(deleted=1) @@ -318,9 +368,21 @@ async def batch_delete_clouds( clouds = result.scalars().all() if len(clouds) != len(payload.ids): 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) 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.execute( + update(User) + .where(User.id == user.id) + .values( + cloud_count=User.cloud_count - len(clouds), + public_cloud_count=User.public_cloud_count - public_count, + ) + ) + await db.commit() await delete_files(paths) return DeleteResultOut(deleted=len(clouds)) diff --git a/app/routers/favorites.py b/app/routers/favorites.py index fcfaffb..9d8ccd6 100644 --- a/app/routers/favorites.py +++ b/app/routers/favorites.py @@ -2,7 +2,7 @@ import math import uuid from fastapi import APIRouter, HTTPException, Query, status -from sqlalchemy import delete, func, select +from sqlalchemy import case, delete, func, select, update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.orm import joinedload @@ -18,7 +18,7 @@ router = APIRouter(tags=["点赞收藏"]) def _cloud_options(): return ( - joinedload(Cloud.user).joinedload(User.profile), + joinedload(Cloud.user), joinedload(Cloud.cloud_type), ) @@ -35,23 +35,12 @@ async def _get_visible_cloud( 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" + can_manage = cloud.user_id == user.id or user.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( @@ -71,11 +60,12 @@ async def _favorite_out( ) -> FavoriteOut: if favorited is None: favorited = await _is_favorited(db, user_id, cloud_id) + cloud = await db.get(Cloud, cloud_id) return FavoriteOut( cloud_id=cloud_id, user_id=user_id, favorited=favorited, - favorite_count=await _favorite_count(db, cloud_id), + favorite_count=cloud.favorite_count if cloud else 0, ) @@ -89,11 +79,10 @@ async def get_cloud_favorite( 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": + if target_user_id != user.id and user.role != "admin": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="只能查询自己的喜欢状态", @@ -109,27 +98,43 @@ async def set_cloud_favorite( 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( + result = 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] ) ) + if result.rowcount and result.rowcount > 0: + await db.execute( + update(Cloud) + .where(Cloud.id == cloud_id) + .values(favorite_count=Cloud.favorite_count + 1) + ) else: - await db.execute( + result = await db.execute( delete(CloudFavorite).where( CloudFavorite.user_id == user.id, CloudFavorite.cloud_id == cloud_id, ) ) + if result.rowcount and result.rowcount > 0: + await db.execute( + update(Cloud) + .where(Cloud.id == cloud_id) + .values( + favorite_count=case( + (Cloud.favorite_count > 0, Cloud.favorite_count - 1), + else_=0, + ) + ) + ) await db.commit() return await _favorite_out( @@ -147,7 +152,6 @@ async def list_my_favorites( page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=100), ) -> PageOut[CloudOut]: - """当前用户点赞收藏的照片列表(按收藏时间倒序)。""" total = ( await db.scalar( select(func.count()) @@ -166,7 +170,7 @@ async def list_my_favorites( .limit(page_size) ) items = [ - cloud_out(item, include_private=item.user_id == user.id or user.profile.role == "admin") + cloud_out(item, include_private=item.user_id == user.id or user.role == "admin") for item in result.scalars().unique().all() ] return PageOut( diff --git a/app/routers/profiles.py b/app/routers/profiles.py index 76064db..1281fef 100644 --- a/app/routers/profiles.py +++ b/app/routers/profiles.py @@ -3,21 +3,22 @@ import uuid from typing import Annotated from fastapi import APIRouter, File, HTTPException, Query, UploadFile, status -from sqlalchemy import func, select +from sqlalchemy import func, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import joinedload from app.deps import CurrentUser, DbSession, OptionalUser -from app.models import Cloud, Profile, User, UserCollection +from app.models import Cloud, CloudFavorite, User, UserCollection from app.schemas import ( CloudOut, + LikeStatsOut, + LikeStatsPhotoOut, PageOut, - ProfileOut, ProfileStatsOut, ProfileUpdateIn, - PublicProfileOut, + UserOut, ) -from app.serializers import cloud_out, profile_out, public_profile_out +from app.serializers import cloud_out, media_url, user_out from app.services.storage import delete_files, save_avatar @@ -29,60 +30,73 @@ router = APIRouter(prefix="/profiles", tags=["用户资料"]) # --------------------------------------------------------------------------- -@router.get("/me", response_model=PublicProfileOut) -async def get_my_profile(user: CurrentUser, db: DbSession) -> PublicProfileOut: - return public_profile_out(user.profile) +@router.get("/me", response_model=UserOut) +async def get_my_profile(user: CurrentUser) -> UserOut: + return user_out(user) -@router.patch("/me", response_model=ProfileOut) +@router.patch("/me", response_model=UserOut) async def update_my_profile( payload: ProfileUpdateIn, user: CurrentUser, db: DbSession, -) -> ProfileOut: - user.profile.username = payload.username +) -> UserOut: + user.username = payload.username try: await db.commit() except IntegrityError as exc: await db.rollback() raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="这个昵称已经被使用") from exc - return profile_out(user.profile) + return user_out(user) -@router.post("/me/avatar", response_model=ProfileOut) +@router.post("/me/avatar", response_model=UserOut) async def upload_avatar( image: Annotated[UploadFile, File()], user: CurrentUser, db: DbSession, -) -> ProfileOut: - if user.profile.avatar_path: - old_path = user.profile.avatar_path - user.profile.avatar_path = None +) -> UserOut: + if user.avatar_path: + old_path = user.avatar_path + user.avatar_path = None await db.commit() await delete_files([old_path]) avatar_path = await save_avatar(image, user.id) - user.profile.avatar_path = avatar_path + user.avatar_path = avatar_path await db.commit() - return profile_out(user.profile) + return user_out(user) -@router.delete("/me/avatar", response_model=ProfileOut) +@router.delete("/me/avatar", response_model=UserOut) async def delete_avatar( user: CurrentUser, db: DbSession, -) -> ProfileOut: - if not user.profile.avatar_path: +) -> UserOut: + if not user.avatar_path: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="没有头像") - old_path = user.profile.avatar_path - user.profile.avatar_path = None + old_path = user.avatar_path + user.avatar_path = None await db.commit() await delete_files([old_path]) - return profile_out(user.profile) + return user_out(user) @router.get("/me/stats", response_model=ProfileStatsOut) -async def get_my_stats(user: CurrentUser, db: DbSession) -> ProfileStatsOut: - return await _build_stats(user.id, db) +async def get_my_stats(user: CurrentUser) -> ProfileStatsOut: + return ProfileStatsOut( + user_id=user.id, + username=user.username, + email=user.email, + created_at=user.created_at, + cloud_count=user.cloud_count, + public_cloud_count=user.public_cloud_count, + collection_count=user.collection_count, + ) + + +@router.get("/me/like-stats", response_model=LikeStatsOut) +async def get_my_like_stats(user: CurrentUser, db: DbSession) -> LikeStatsOut: + return await _build_like_stats(user.id, db) @router.get("/me/clouds", response_model=PageOut[CloudOut]) @@ -92,7 +106,7 @@ async def get_my_clouds( page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=100), ) -> PageOut[CloudOut]: - return await _list_profile_clouds(user.id, db, viewer_role=user.profile.role, viewer_id=user.id, page=page, page_size=page_size) + return await _list_profile_clouds(user.id, db, viewer_role=user.role, viewer_id=user.id, page=page, page_size=page_size) # --------------------------------------------------------------------------- @@ -100,12 +114,12 @@ async def get_my_clouds( # --------------------------------------------------------------------------- -@router.get("/{user_id}", response_model=PublicProfileOut) -async def get_profile(user_id: uuid.UUID, db: DbSession) -> PublicProfileOut: - profile = await db.get(Profile, user_id) - if not profile: +@router.get("/{user_id}", response_model=UserOut) +async def get_profile(user_id: uuid.UUID, db: DbSession) -> UserOut: + user = await db.get(User, user_id) + if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") - return public_profile_out(profile) + return user_out(user) @router.get("/{user_id}/stats", response_model=ProfileStatsOut) @@ -114,9 +128,31 @@ async def get_user_stats( viewer: CurrentUser, db: DbSession, ) -> ProfileStatsOut: - if viewer.id != user_id and viewer.profile.role != "admin": + if viewer.id != user_id and viewer.role != "admin": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只能查看自己的统计数据") - return await _build_stats(user_id, db) + user = await db.get(User, user_id) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") + return ProfileStatsOut( + user_id=user.id, + username=user.username, + email=user.email, + created_at=user.created_at, + cloud_count=user.cloud_count, + public_cloud_count=user.public_cloud_count, + collection_count=user.collection_count, + ) + + +@router.get("/{user_id}/like-stats", response_model=LikeStatsOut) +async def get_user_like_stats( + user_id: uuid.UUID, + viewer: CurrentUser, + db: DbSession, +) -> LikeStatsOut: + if viewer.id != user_id and viewer.role != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只能查看自己的点赞统计数据") + return await _build_like_stats(user_id, db) @router.get("/{user_id}/clouds", response_model=PageOut[CloudOut]) @@ -127,7 +163,7 @@ async def get_profile_clouds( page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=100), ) -> PageOut[CloudOut]: - viewer_role = viewer.profile.role if viewer else None + viewer_role = viewer.role if viewer else None viewer_id = viewer.id if viewer else None return await _list_profile_clouds(user_id, db, viewer_role=viewer_role, viewer_id=viewer_id, page=page, page_size=page_size) @@ -137,45 +173,46 @@ async def get_profile_clouds( # --------------------------------------------------------------------------- -async def _build_stats(user_id: uuid.UUID, db: DbSession) -> ProfileStatsOut: - result = await db.execute( - select(User).options(joinedload(User.profile)).where(User.id == user_id) - ) - user = result.scalar_one_or_none() +async def _build_like_stats(user_id: uuid.UUID, db: DbSession) -> LikeStatsOut: + user = await db.get(User, user_id) if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") - cloud_count = ( - await db.scalar(select(func.count()).select_from(Cloud).where(Cloud.user_id == user_id)) - or 0 + other_likes = func.count(CloudFavorite.id).filter( + CloudFavorite.user_id != Cloud.user_id ) - public_cloud_count = ( - await db.scalar( - select(func.count()) - .select_from(Cloud) - .where( - Cloud.user_id == user_id, - Cloud.status == "approved", - Cloud.is_hidden.is_(False), - ) + rows = await db.execute( + select( + Cloud.id, + Cloud.image_path, + Cloud.thumbnail_path, + Cloud.captured_at, + Cloud.created_at, + other_likes.label("favorite_count"), ) - or 0 + .outerjoin(CloudFavorite, CloudFavorite.cloud_id == Cloud.id) + .where(Cloud.user_id == user_id) + .group_by(Cloud.id, Cloud.image_path, Cloud.thumbnail_path, Cloud.captured_at, Cloud.created_at) + .order_by(other_likes.desc(), Cloud.created_at.desc()) ) - collection_count = ( - await db.scalar( - select(func.count()).select_from(UserCollection).where(UserCollection.user_id == user_id) + photos = [ + LikeStatsPhotoOut( + cloud_id=row.id, + image_url=media_url(row.image_path) or "", + thumbnail_url=media_url(row.thumbnail_path) or "", + favorite_count=row.favorite_count, + captured_at=row.captured_at, + created_at=row.created_at, ) - or 0 - ) + for row in rows + ] + total_likes = sum(p.favorite_count for p in photos) - return ProfileStatsOut( + return LikeStatsOut( user_id=user.id, - username=user.profile.username, - email=user.email, - created_at=user.created_at, - cloud_count=cloud_count, - public_cloud_count=public_cloud_count, - collection_count=collection_count, + username=user.username, + total_likes=total_likes, + photos=photos, ) @@ -188,7 +225,8 @@ async def _list_profile_clouds( page: int, page_size: int, ) -> PageOut[CloudOut]: - if not await db.get(Profile, user_id): + user = await db.get(User, user_id) + if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") can_manage = bool(viewer_id and (viewer_id == user_id or viewer_role == "admin")) @@ -199,7 +237,7 @@ async def _list_profile_clouds( total = await db.scalar(select(func.count()).select_from(Cloud).where(*filters)) or 0 result = await db.execute( select(Cloud) - .options(joinedload(Cloud.user).joinedload(User.profile), joinedload(Cloud.cloud_type)) + .options(joinedload(Cloud.user), joinedload(Cloud.cloud_type)) .where(*filters) .order_by(Cloud.captured_at.desc().nulls_last(), Cloud.created_at.desc(), Cloud.id.desc()) .offset((page - 1) * page_size) diff --git a/app/schemas.py b/app/schemas.py index facd7cd..fc4d141 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -49,30 +49,19 @@ class ChangePasswordIn(BaseModel): password: str = Field(min_length=8, max_length=128) -class ProfileOut(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: uuid.UUID - username: str - avatar_url: str | None = None - role: Role - is_disabled: bool - created_at: datetime - - -class PublicProfileOut(BaseModel): - id: uuid.UUID - username: str - avatar_url: str | None = None - created_at: datetime - - class UserOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: uuid.UUID email: EmailStr email_verified: bool + username: str + avatar_url: str | None = None + role: Role + is_disabled: bool + cloud_count: int + public_cloud_count: int + collection_count: int created_at: datetime @@ -81,12 +70,10 @@ class AuthOut(BaseModel): token_type: Literal["bearer"] = "bearer" expires_in: int user: UserOut - profile: ProfileOut class MeOut(BaseModel): user: UserOut - profile: ProfileOut class ProfileUpdateIn(BaseModel): @@ -98,6 +85,16 @@ class ProfileUpdateIn(BaseModel): return value.strip() +class ProfileStatsOut(BaseModel): + user_id: uuid.UUID + username: str + email: str + created_at: datetime + cloud_count: int + public_cloud_count: int + collection_count: int + + class CloudTypeSummary(BaseModel): id: int name: str @@ -138,6 +135,8 @@ class CloudOut(BaseModel): cloud_type_name: str cloud_type_rarity: Rarity username: str + favorite_count: int = 0 + is_favorited: bool = False class CloudUpdateIn(BaseModel): @@ -228,7 +227,6 @@ class AdminStatsOut(BaseModel): class AdminUserOut(BaseModel): user: UserOut - profile: ProfileOut class AdminUserUpdateIn(BaseModel): @@ -269,19 +267,7 @@ class BatchUpdateOut(BaseModel): updated: int -class ProfileStatsOut(BaseModel): - user_id: uuid.UUID - username: str - email: str - created_at: datetime - cloud_count: int - public_cloud_count: int - collection_count: int - - class FavoriteIn(BaseModel): - """设置或取消对照片的点赞收藏。""" - favorited: bool @@ -290,3 +276,19 @@ class FavoriteOut(BaseModel): user_id: uuid.UUID favorited: bool favorite_count: int + + +class LikeStatsPhotoOut(BaseModel): + cloud_id: uuid.UUID + image_url: str + thumbnail_url: str + favorite_count: int + captured_at: datetime | None + created_at: datetime + + +class LikeStatsOut(BaseModel): + user_id: uuid.UUID + username: str + total_likes: int + photos: list[LikeStatsPhotoOut] diff --git a/app/seed.py b/app/seed.py index 3f5fd48..52b54ba 100644 --- a/app/seed.py +++ b/app/seed.py @@ -5,7 +5,7 @@ from sqlalchemy.orm import selectinload from app.config import settings from app.database import SessionLocal -from app.models import CloudType, Profile, User +from app.models import CloudType, User from app.security import hash_password, utc_now @@ -36,20 +36,21 @@ async def seed() -> None: if settings.admin_email and settings.admin_password: email = settings.admin_email.strip().lower() result = await db.execute( - select(User).options(selectinload(User.profile)).where(User.email == email) + select(User).where(User.email == email) ) admin = result.scalar_one_or_none() if admin: - admin.profile.role = "admin" - admin.profile.is_disabled = False + admin.role = "admin" + admin.is_disabled = False admin.email_verified_at = admin.email_verified_at or utc_now() else: admin = User( email=email, password_hash=hash_password(settings.admin_password), email_verified_at=utc_now(), + username=settings.admin_username, + role="admin", ) - admin.profile = Profile(username=settings.admin_username, role="admin") db.add(admin) await db.commit() diff --git a/app/serializers.py b/app/serializers.py index 4e7cec8..ab95659 100644 --- a/app/serializers.py +++ b/app/serializers.py @@ -1,12 +1,10 @@ from app.config import settings -from app.models import Cloud, CloudType, Profile, User +from app.models import Cloud, CloudType, User from app.schemas import ( CloudOut, CloudOwnerOut, CloudTypeOut, CloudTypeSummary, - ProfileOut, - PublicProfileOut, UserOut, ) @@ -17,31 +15,18 @@ def media_url(path: str | None) -> str | None: return f"{settings.media_base_url}/{path.lstrip('/')}" -def profile_out(profile: Profile) -> ProfileOut: - return ProfileOut( - id=profile.id, - username=profile.username, - avatar_url=media_url(profile.avatar_path), - role=profile.role, - is_disabled=profile.is_disabled, - created_at=profile.created_at, - ) - - -def public_profile_out(profile: Profile) -> PublicProfileOut: - return PublicProfileOut( - id=profile.id, - username=profile.username, - avatar_url=media_url(profile.avatar_path), - created_at=profile.created_at, - ) - - def user_out(user: User) -> UserOut: return UserOut( id=user.id, email=user.email, email_verified=user.email_verified_at is not None, + username=user.username, + avatar_url=media_url(user.avatar_path), + role=user.role, + is_disabled=user.is_disabled, + cloud_count=user.cloud_count, + public_cloud_count=user.public_cloud_count, + collection_count=user.collection_count, created_at=user.created_at, ) @@ -68,9 +53,15 @@ def cloud_type_out(cloud_type: CloudType) -> CloudTypeOut: ) -def cloud_out(cloud: Cloud, *, include_private: bool = True) -> CloudOut: +def cloud_out( + cloud: Cloud, + *, + include_private: bool = True, + favorite_count: int | None = None, + is_favorited: bool = False, +) -> CloudOut: cloud_type = cloud.cloud_type - profile = cloud.user.profile + owner = cloud.user type_name = cloud_type.name if cloud_type else (cloud.custom_cloud_type or "未知") rarity = cloud_type.rarity if cloud_type else "common" return CloudOut( @@ -90,8 +81,10 @@ def cloud_out(cloud: Cloud, *, include_private: bool = True) -> CloudOut: created_at=cloud.created_at, updated_at=cloud.updated_at, cloud_type=cloud_type_summary(cloud_type) if cloud_type else None, - owner=CloudOwnerOut(id=profile.id, username=profile.username), + owner=CloudOwnerOut(id=owner.id, username=owner.username), cloud_type_name=type_name, cloud_type_rarity=rarity, - username=profile.username, + username=owner.username, + favorite_count=favorite_count if favorite_count is not None else 0, + is_favorited=is_favorited, ) diff --git a/tests/test_admin.py b/tests/test_admin.py index 1d13efa..e58e08c 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -71,7 +71,7 @@ def test_create_user(client: TestClient) -> None: assert r.status_code == 201 body = r.json() assert body["user"]["email_verified"] is True - assert body["profile"]["role"] == "user" + assert body["user"]["role"] == "user" def test_create_user_with_role(client: TestClient) -> None: @@ -87,7 +87,7 @@ def test_create_user_with_role(client: TestClient) -> None: }, ) assert r.status_code == 201 - assert r.json()["profile"]["role"] == "admin" + assert r.json()["user"]["role"] == "admin" def test_create_user_duplicate_email(client: TestClient) -> None: @@ -141,7 +141,7 @@ def test_update_user_role(client: TestClient, monkeypatch) -> None: json={"role": "admin"}, ) assert r.status_code == 200 - assert r.json()["profile"]["role"] == "admin" + assert r.json()["user"]["role"] == "admin" def test_update_user_disable(client: TestClient, monkeypatch) -> None: @@ -157,7 +157,7 @@ def test_update_user_disable(client: TestClient, monkeypatch) -> None: json={"is_disabled": True}, ) assert r.status_code == 200 - assert r.json()["profile"]["is_disabled"] is True + assert r.json()["user"]["is_disabled"] is True client.patch( f"/api/v1/admin/users/{info['user_id']}", diff --git a/tests/test_auth.py b/tests/test_auth.py index fcbdff2..6ea2eee 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -264,7 +264,7 @@ def test_me(client: TestClient, monkeypatch) -> None: assert r.status_code == 200 body = r.json() assert body["user"]["email"] == email - assert body["profile"]["username"] == "我的信息" + assert body["user"]["username"] == "我的信息" def test_me_unauthenticated(client: TestClient) -> None: