重构表结构:合并 profiles→users,展平 API 响应,新增计数器字段

- 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
This commit is contained in:
2026-07-28 23:48:57 +08:00
parent 118c5cbe96
commit e75833ee4b
14 changed files with 519 additions and 285 deletions
@@ -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")
+2 -3
View File
@@ -34,7 +34,6 @@ async def _resolve_user(
(RefreshSession.id == payload.session_id) (RefreshSession.id == payload.session_id)
& (RefreshSession.user_id == User.id), & (RefreshSession.user_id == User.id),
) )
.options(selectinload(User.profile))
.where( .where(
User.id == payload.user_id, User.id == payload.user_id,
RefreshSession.revoked_at.is_(None), RefreshSession.revoked_at.is_(None),
@@ -44,7 +43,7 @@ async def _resolve_user(
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录状态已失效") 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="账号已被禁用") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用")
return user return user
@@ -68,7 +67,7 @@ async def get_optional_user(
async def get_admin_user(user: Annotated[User, Depends(get_current_user)]) -> 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="需要管理员权限") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
return user return user
+11 -52
View File
@@ -8,6 +8,7 @@ from sqlalchemy import (
DateTime, DateTime,
ForeignKey, ForeignKey,
Index, Index,
Integer,
Numeric, Numeric,
SmallInteger, SmallInteger,
Text, Text,
@@ -32,42 +33,28 @@ class TimestampMixin:
class User(Base, TimestampMixin): class User(Base, TimestampMixin):
__tablename__ = "users" __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) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(Text, unique=True, nullable=False) email: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False) password_hash: Mapped[str] = mapped_column(Text, nullable=False)
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) 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) username: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
avatar_path: Mapped[str | None] = mapped_column(Text) avatar_path: Mapped[str | None] = mapped_column(Text)
role: Mapped[str] = mapped_column(Text, nullable=False, default="user", server_default="user") role: Mapped[str] = mapped_column(Text, nullable=False, default="user", server_default="user")
is_disabled: Mapped[bool] = mapped_column( is_disabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=text("false") 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): class RefreshSession(Base):
@@ -163,28 +150,7 @@ class Cloud(Base, TimestampMixin):
), ),
Index("ix_clouds_user_id", "user_id"), Index("ix_clouds_user_id", "user_id"),
Index("ix_clouds_cloud_type_id", "cloud_type_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_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) 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( is_hidden: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=text("false") 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") user: Mapped[User] = relationship(back_populates="clouds", lazy="raise")
cloud_type: Mapped[CloudType | None] = relationship(lazy="raise") cloud_type: Mapped[CloudType | None] = relationship(lazy="raise")
@@ -239,19 +206,11 @@ class UserCollection(Base):
class CloudFavorite(Base): class CloudFavorite(Base):
"""用户对照片的点赞/收藏记录。"""
__tablename__ = "cloud_favorites" __tablename__ = "cloud_favorites"
__table_args__ = ( __table_args__ = (
UniqueConstraint("user_id", "cloud_id", name="uq_cloud_favorites_user_cloud"), UniqueConstraint("user_id", "cloud_id", name="uq_cloud_favorites_user_cloud"),
Index("ix_cloud_favorites_user_id", "user_id"), Index("ix_cloud_favorites_user_id", "user_id"),
Index("ix_cloud_favorites_cloud_id", "cloud_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) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
+86 -16
View File
@@ -7,10 +7,10 @@ from zoneinfo import ZoneInfo
from fastapi import APIRouter, HTTPException, Query, status from fastapi import APIRouter, HTTPException, Query, status
from sqlalchemy import case, func, select, update from sqlalchemy import case, func, select, update
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm import joinedload
from app.deps import AdminUser, DbSession from app.deps import AdminUser, DbSession
from app.models import Cloud, Profile, User from app.models import Cloud, User
from app.schemas import ( from app.schemas import (
AdminCloudStatusIn, AdminCloudStatusIn,
AdminCloudVisibilityIn, AdminCloudVisibilityIn,
@@ -25,7 +25,7 @@ from app.schemas import (
PageOut, PageOut,
) )
from app.security import hash_password, utc_now 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 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 total = await db.scalar(select(func.count()).select_from(User)) or 0
result = await db.execute( result = await db.execute(
select(User) select(User)
.options(selectinload(User.profile))
.order_by(User.created_at.desc(), User.id.desc()) .order_by(User.created_at.desc(), User.id.desc())
.offset((page - 1) * page_size) .offset((page - 1) * page_size)
.limit(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( return PageOut(
items=items, items=items,
page=page, page=page,
@@ -97,8 +96,9 @@ async def create_user(
email=email, email=email,
password_hash=hash_password(payload.password), password_hash=hash_password(payload.password),
email_verified_at=utc_now(), email_verified_at=utc_now(),
username=payload.username,
role=payload.role,
) )
user.profile = Profile(username=payload.username, role=payload.role)
db.add(user) db.add(user)
try: try:
await db.commit() await db.commit()
@@ -108,8 +108,8 @@ async def create_user(
if "email" in message: 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
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"]) await db.refresh(user)
return AdminUserOut(user=user_out(user), profile=profile_out(user.profile)) return AdminUserOut(user=user_out(user))
@router.patch("/users/{user_id}", response_model=AdminUserOut) @router.patch("/users/{user_id}", response_model=AdminUserOut)
@@ -119,10 +119,7 @@ async def update_user(
admin: AdminUser, admin: AdminUser,
db: DbSession, db: DbSession,
) -> AdminUserOut: ) -> AdminUserOut:
result = await db.execute( user = await db.get(User, user_id)
select(User).options(selectinload(User.profile)).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user: if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
if user.id == admin.id and ( if user.id == admin.id and (
@@ -130,11 +127,11 @@ async def update_user(
): ):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能禁用自己或移除自己的管理员权限") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能禁用自己或移除自己的管理员权限")
if payload.role is not None: if payload.role is not None:
user.profile.role = payload.role user.role = payload.role
if payload.is_disabled is not None: if payload.is_disabled is not None:
user.profile.is_disabled = payload.is_disabled user.is_disabled = payload.is_disabled
await db.commit() 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]) @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 total = await db.scalar(select(func.count()).select_from(Cloud).where(*filters)) or 0
result = await db.execute( result = await db.execute(
select(Cloud) select(Cloud)
.options(joinedload(Cloud.user).joinedload(User.profile), joinedload(Cloud.cloud_type)) .options(joinedload(Cloud.user), joinedload(Cloud.cloud_type))
.where(*filters) .where(*filters)
.order_by(Cloud.created_at.desc(), Cloud.id.desc()) .order_by(Cloud.created_at.desc(), Cloud.id.desc())
.offset((page - 1) * page_size) .offset((page - 1) * page_size)
@@ -182,6 +179,37 @@ async def update_cloud_status(
db: DbSession, db: DbSession,
) -> BatchUpdateOut: ) -> BatchUpdateOut:
await _require_all_cloud_ids(db, payload.ids) 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( await db.execute(
update(Cloud) update(Cloud)
.where(Cloud.id.in_(payload.ids)) .where(Cloud.id.in_(payload.ids))
@@ -198,6 +226,37 @@ async def update_cloud_visibility(
db: DbSession, db: DbSession,
) -> BatchUpdateOut: ) -> BatchUpdateOut:
await _require_all_cloud_ids(db, payload.ids) 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( await db.execute(
update(Cloud) update(Cloud)
.where(Cloud.id.in_(payload.ids)) .where(Cloud.id.in_(payload.ids))
@@ -218,8 +277,19 @@ async def delete_admin_clouds(
if len(clouds) != len(payload.ids): if len(clouds) != len(payload.ids):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部分图片不存在") 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)] 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: for cloud in clouds:
user_ids[cloud.user_id] = user_ids.get(cloud.user_id, 0) + 1
await db.delete(cloud) 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 db.commit()
await delete_files(paths) await delete_files(paths)
return DeleteResultOut(deleted=len(clouds)) return DeleteResultOut(deleted=len(clouds))
+53 -42
View File
@@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload
from app.config import settings from app.config import settings
from app.deps import CurrentUser, DbSession 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 ( from app.schemas import (
AuthOut, AuthOut,
ChangePasswordIn, ChangePasswordIn,
@@ -30,7 +30,7 @@ from app.security import (
utc_now, utc_now,
verify_password, 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 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: async def _create_email_token(db: DbSession, user: User, purpose: str, expires_delta: timedelta) -> str:
now = utc_now() now = utc_now()
await db.execute( 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) @router.post("/register", response_model=MessageOut, status_code=status.HTTP_201_CREATED)
async def register(payload: RegisterIn, db: DbSession) -> MessageOut: async def register(payload: RegisterIn, db: DbSession) -> MessageOut:
email = str(payload.email).strip().lower() email = str(payload.email).strip().lower()
user = User(email=email, password_hash=hash_password(payload.password)) user = User(email=email, password_hash=hash_password(payload.password), username=payload.username)
user.profile = Profile(username=payload.username)
db.add(user) db.add(user)
try: try:
await db.flush() await db.flush()
@@ -194,9 +171,7 @@ async def confirm_email(payload: TokenIn, db: DbSession) -> MessageOut:
@router.post("/login", response_model=AuthOut) @router.post("/login", response_model=AuthOut)
async def login(payload: LoginIn, request: Request, response: Response, db: DbSession) -> AuthOut: async def login(payload: LoginIn, request: Request, response: Response, db: DbSession) -> AuthOut:
email = str(payload.email).strip().lower() email = str(payload.email).strip().lower()
result = await db.execute( result = await db.execute(select(User).where(User.email == email))
select(User).options(selectinload(User.profile)).where(User.email == email)
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
encoded_hash = user.password_hash if user else DUMMY_PASSWORD_HASH encoded_hash = user.password_hash if user else DUMMY_PASSWORD_HASH
password_valid = verify_password(payload.password, encoded_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="邮箱或密码错误") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码错误")
if user.email_verified_at is None: if user.email_verified_at is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="邮箱尚未确认") 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="账号已被禁用") 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) db.add(session)
await db.commit() await db.commit()
_set_refresh_cookie(response, refresh_token) _set_refresh_cookie(response, token)
return _auth_response(user, session) 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) @router.post("/refresh", response_model=AuthOut)
@@ -222,7 +209,7 @@ async def refresh(request: Request, response: Response, db: DbSession) -> AuthOu
now = utc_now() now = utc_now()
result = await db.execute( result = await db.execute(
select(RefreshSession) select(RefreshSession)
.options(selectinload(RefreshSession.user).selectinload(User.profile)) .options(selectinload(RefreshSession.user))
.where(RefreshSession.token_hash == hash_token(raw_token)) .where(RefreshSession.token_hash == hash_token(raw_token))
.with_for_update() .with_for_update()
) )
@@ -235,20 +222,32 @@ async def refresh(request: Request, response: Response, db: DbSession) -> AuthOu
_clear_refresh_cookie(response) _clear_refresh_cookie(response)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="刷新令牌无效或已过期") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="刷新令牌无效或已过期")
user = old_session.user user = old_session.user
if user.profile.is_disabled: if user.is_disabled:
old_session.revoked_at = now old_session.revoked_at = now
await db.commit() await db.commit()
_clear_refresh_cookie(response) _clear_refresh_cookie(response)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用") 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) db.add(new_session)
await db.flush() await db.flush()
old_session.revoked_at = now old_session.revoked_at = now
old_session.replaced_by_id = new_session.id old_session.replaced_by_id = new_session.id
await db.commit() await db.commit()
_set_refresh_cookie(response, new_token) _set_refresh_cookie(response, token)
return _auth_response(user, new_session) 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) @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) @router.get("/me", response_model=MeOut)
async def me(user: CurrentUser) -> 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) @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)) .where(RefreshSession.user_id == user.id, RefreshSession.revoked_at.is_(None))
.values(revoked_at=now) .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) db.add(new_session)
await db.commit() await db.commit()
_set_refresh_cookie(response, refresh_token) _set_refresh_cookie(response, token)
return _auth_response(user, new_session) return AuthOut(
access_token=create_access_token(user.id, new_session.id),
expires_in=settings.access_token_expire_minutes * 60,
user=user_out(user),
)
+1 -1
View File
@@ -44,7 +44,7 @@ async def get_cloud_type_clouds(
total = await db.scalar(select(func.count()).select_from(Cloud).where(*filters)) or 0 total = await db.scalar(select(func.count()).select_from(Cloud).where(*filters)) or 0
result = await db.execute( result = await db.execute(
select(Cloud) select(Cloud)
.options(joinedload(Cloud.user).joinedload(User.profile), joinedload(Cloud.cloud_type)) .options(joinedload(Cloud.user), joinedload(Cloud.cloud_type))
.where(*filters) .where(*filters)
.order_by(Cloud.captured_at.desc().nulls_last(), Cloud.created_at.desc(), Cloud.id.desc()) .order_by(Cloud.captured_at.desc().nulls_last(), Cloud.created_at.desc(), Cloud.id.desc())
.offset((page - 1) * page_size) .offset((page - 1) * page_size)
+73 -11
View File
@@ -5,13 +5,13 @@ from decimal import Decimal, ROUND_HALF_UP
from typing import Annotated, Literal from typing import Annotated, Literal
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, status 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.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from app.deps import CurrentUser, DbSession, OptionalUser 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 ( from app.schemas import (
BadgeOut, BadgeOut,
BatchIdsIn, BatchIdsIn,
@@ -30,7 +30,7 @@ router = APIRouter(prefix="/clouds", tags=["云图"])
def _cloud_options(): def _cloud_options():
return ( return (
joinedload(Cloud.user).joinedload(User.profile), joinedload(Cloud.user),
joinedload(Cloud.cloud_type), joinedload(Cloud.cloud_type),
) )
@@ -93,9 +93,9 @@ async def list_gallery_clouds(
username = term[1:].strip() username = term[1:].strip()
if not username: if not username:
return PageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1) return PageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
data_query = data_query.join(Cloud.user).join(User.profile) data_query = data_query.join(Cloud.user)
count_query = count_query.join(Cloud.user).join(User.profile) count_query = count_query.join(Cloud.user)
filters.append(Profile.username.ilike(f"%{username}%")) filters.append(User.username.ilike(f"%{username}%"))
elif term: elif term:
data_query = data_query.outerjoin(Cloud.cloud_type) data_query = data_query.outerjoin(Cloud.cloud_type)
count_query = count_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) cloud = await _get_cloud(db, cloud_id)
if not cloud: if not cloud:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在") 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): if not can_manage and (cloud.status != "approved" or cloud.is_hidden):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在") 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) @router.post("", response_model=CloudCreateOut, status_code=status.HTTP_201_CREATED)
@@ -207,6 +219,13 @@ async def create_cloud(
unlocked_badge: BadgeOut | None = None unlocked_badge: BadgeOut | None = None
try: try:
await db.flush() await db.flush()
await db.execute(
update(User)
.where(User.id == user.id)
.values(cloud_count=User.cloud_count + 1)
)
if cloud_type: if cloud_type:
insert_factory = pg_insert if db.bind and db.bind.dialect.name == "postgresql" else sqlite_insert insert_factory = pg_insert if db.bind and db.bind.dialect.name == "postgresql" else sqlite_insert
insert_result = await db.execute( insert_result = await db.execute(
@@ -223,6 +242,11 @@ async def create_cloud(
) )
unlocked_at = insert_result.scalar_one_or_none() unlocked_at = insert_result.scalar_one_or_none()
if unlocked_at: if unlocked_at:
await db.execute(
update(User)
.where(User.id == user.id)
.values(collection_count=User.collection_count + 1)
)
unlocked_badge = BadgeOut( unlocked_badge = BadgeOut(
cloud_type_id=cloud_type.id, cloud_type_id=cloud_type.id,
cloud_name=cloud_type.name, cloud_name=cloud_type.name,
@@ -252,9 +276,10 @@ async def update_cloud(
cloud = await _get_cloud(db, cloud_id) cloud = await _get_cloud(db, cloud_id)
if not cloud: if not cloud:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在") 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="不能修改其他用户的图片") 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) changes = payload.model_dump(exclude_unset=True)
final_type_id = changes.get("cloud_type_id", cloud.cloud_type_id) 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)) 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"]) cloud.captured_at = _normalize_datetime(changes["captured_at"])
if "is_hidden" in changes: if "is_hidden" in changes:
cloud.is_hidden = changes["is_hidden"] 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) updated = await _get_cloud(db, cloud.id)
if not updated: if not updated:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在") 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) cloud = await _get_cloud(db, cloud_id)
if not cloud: if not cloud:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在") 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="不能删除其他用户的图片") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能删除其他用户的图片")
paths = [cloud.image_path, cloud.thumbnail_path] paths = [cloud.image_path, cloud.thumbnail_path]
was_public = cloud.status == "approved" and not cloud.is_hidden
await db.delete(cloud) 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 db.commit()
await delete_files(paths) await delete_files(paths)
return DeleteResultOut(deleted=1) return DeleteResultOut(deleted=1)
@@ -318,9 +368,21 @@ async def batch_delete_clouds(
clouds = result.scalars().all() clouds = result.scalars().all()
if len(clouds) != len(payload.ids): if len(clouds) != len(payload.ids):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部分图片不存在或不属于当前用户") 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)] paths = [path for cloud in clouds for path in (cloud.image_path, cloud.thumbnail_path)]
for cloud in clouds: for cloud in clouds:
await db.delete(cloud) 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 db.commit()
await delete_files(paths) await delete_files(paths)
return DeleteResultOut(deleted=len(clouds)) return DeleteResultOut(deleted=len(clouds))
+26 -22
View File
@@ -2,7 +2,7 @@ import math
import uuid import uuid
from fastapi import APIRouter, HTTPException, Query, status 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.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
@@ -18,7 +18,7 @@ router = APIRouter(tags=["点赞收藏"])
def _cloud_options(): def _cloud_options():
return ( return (
joinedload(Cloud.user).joinedload(User.profile), joinedload(Cloud.user),
joinedload(Cloud.cloud_type), joinedload(Cloud.cloud_type),
) )
@@ -35,23 +35,12 @@ async def _get_visible_cloud(
if not cloud: if not cloud:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在") 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): if not can_manage and (cloud.status != "approved" or cloud.is_hidden):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
return cloud 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: async def _is_favorited(db: DbSession, user_id: uuid.UUID, cloud_id: uuid.UUID) -> bool:
favorite_id = await db.scalar( favorite_id = await db.scalar(
select(CloudFavorite.id).where( select(CloudFavorite.id).where(
@@ -71,11 +60,12 @@ async def _favorite_out(
) -> FavoriteOut: ) -> FavoriteOut:
if favorited is None: if favorited is None:
favorited = await _is_favorited(db, user_id, cloud_id) favorited = await _is_favorited(db, user_id, cloud_id)
cloud = await db.get(Cloud, cloud_id)
return FavoriteOut( return FavoriteOut(
cloud_id=cloud_id, cloud_id=cloud_id,
user_id=user_id, user_id=user_id,
favorited=favorited, 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;默认当前登录用户。仅本人或管理员可查他人。", description="要查询的用户 ID;默认当前登录用户。仅本人或管理员可查他人。",
), ),
) -> FavoriteOut: ) -> FavoriteOut:
"""查询某用户对某张照片的点赞/收藏状态。"""
await _get_visible_cloud(db, cloud_id, user) await _get_visible_cloud(db, cloud_id, user)
target_user_id = user_id or user.id 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="只能查询自己的喜欢状态", detail="只能查询自己的喜欢状态",
@@ -109,27 +98,43 @@ async def set_cloud_favorite(
user: CurrentUser, user: CurrentUser,
db: DbSession, db: DbSession,
) -> FavoriteOut: ) -> FavoriteOut:
"""点赞/收藏照片,或取消。"""
await _get_visible_cloud(db, cloud_id, user) await _get_visible_cloud(db, cloud_id, user)
if payload.favorited: if payload.favorited:
insert_factory = ( insert_factory = (
pg_insert if db.bind and db.bind.dialect.name == "postgresql" else sqlite_insert pg_insert if db.bind and db.bind.dialect.name == "postgresql" else sqlite_insert
) )
await db.execute( result = await db.execute(
insert_factory(CloudFavorite) insert_factory(CloudFavorite)
.values(user_id=user.id, cloud_id=cloud_id) .values(user_id=user.id, cloud_id=cloud_id)
.on_conflict_do_nothing( .on_conflict_do_nothing(
index_elements=[CloudFavorite.user_id, CloudFavorite.cloud_id] 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: else:
await db.execute( result = await db.execute(
delete(CloudFavorite).where( delete(CloudFavorite).where(
CloudFavorite.user_id == user.id, CloudFavorite.user_id == user.id,
CloudFavorite.cloud_id == cloud_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() await db.commit()
return await _favorite_out( return await _favorite_out(
@@ -147,7 +152,6 @@ async def list_my_favorites(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100), page_size: int = Query(50, ge=1, le=100),
) -> PageOut[CloudOut]: ) -> PageOut[CloudOut]:
"""当前用户点赞收藏的照片列表(按收藏时间倒序)。"""
total = ( total = (
await db.scalar( await db.scalar(
select(func.count()) select(func.count())
@@ -166,7 +170,7 @@ async def list_my_favorites(
.limit(page_size) .limit(page_size)
) )
items = [ 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() for item in result.scalars().unique().all()
] ]
return PageOut( return PageOut(
+106 -68
View File
@@ -3,21 +3,22 @@ import uuid
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, File, HTTPException, Query, UploadFile, status 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.exc import IntegrityError
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from app.deps import CurrentUser, DbSession, OptionalUser 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 ( from app.schemas import (
CloudOut, CloudOut,
LikeStatsOut,
LikeStatsPhotoOut,
PageOut, PageOut,
ProfileOut,
ProfileStatsOut, ProfileStatsOut,
ProfileUpdateIn, 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 from app.services.storage import delete_files, save_avatar
@@ -29,60 +30,73 @@ router = APIRouter(prefix="/profiles", tags=["用户资料"])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get("/me", response_model=PublicProfileOut) @router.get("/me", response_model=UserOut)
async def get_my_profile(user: CurrentUser, db: DbSession) -> PublicProfileOut: async def get_my_profile(user: CurrentUser) -> UserOut:
return public_profile_out(user.profile) return user_out(user)
@router.patch("/me", response_model=ProfileOut) @router.patch("/me", response_model=UserOut)
async def update_my_profile( async def update_my_profile(
payload: ProfileUpdateIn, payload: ProfileUpdateIn,
user: CurrentUser, user: CurrentUser,
db: DbSession, db: DbSession,
) -> ProfileOut: ) -> UserOut:
user.profile.username = payload.username user.username = payload.username
try: try:
await db.commit() await db.commit()
except IntegrityError as exc: except IntegrityError as exc:
await db.rollback() await db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="这个昵称已经被使用") from exc 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( async def upload_avatar(
image: Annotated[UploadFile, File()], image: Annotated[UploadFile, File()],
user: CurrentUser, user: CurrentUser,
db: DbSession, db: DbSession,
) -> ProfileOut: ) -> UserOut:
if user.profile.avatar_path: if user.avatar_path:
old_path = user.profile.avatar_path old_path = user.avatar_path
user.profile.avatar_path = None user.avatar_path = None
await db.commit() await db.commit()
await delete_files([old_path]) await delete_files([old_path])
avatar_path = await save_avatar(image, user.id) avatar_path = await save_avatar(image, user.id)
user.profile.avatar_path = avatar_path user.avatar_path = avatar_path
await db.commit() 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( async def delete_avatar(
user: CurrentUser, user: CurrentUser,
db: DbSession, db: DbSession,
) -> ProfileOut: ) -> UserOut:
if not user.profile.avatar_path: if not user.avatar_path:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="没有头像") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="没有头像")
old_path = user.profile.avatar_path old_path = user.avatar_path
user.profile.avatar_path = None user.avatar_path = None
await db.commit() await db.commit()
await delete_files([old_path]) await delete_files([old_path])
return profile_out(user.profile) return user_out(user)
@router.get("/me/stats", response_model=ProfileStatsOut) @router.get("/me/stats", response_model=ProfileStatsOut)
async def get_my_stats(user: CurrentUser, db: DbSession) -> ProfileStatsOut: async def get_my_stats(user: CurrentUser) -> ProfileStatsOut:
return await _build_stats(user.id, db) 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]) @router.get("/me/clouds", response_model=PageOut[CloudOut])
@@ -92,7 +106,7 @@ async def get_my_clouds(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100), page_size: int = Query(50, ge=1, le=100),
) -> PageOut[CloudOut]: ) -> 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) @router.get("/{user_id}", response_model=UserOut)
async def get_profile(user_id: uuid.UUID, db: DbSession) -> PublicProfileOut: async def get_profile(user_id: uuid.UUID, db: DbSession) -> UserOut:
profile = await db.get(Profile, user_id) user = await db.get(User, user_id)
if not profile: if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") 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) @router.get("/{user_id}/stats", response_model=ProfileStatsOut)
@@ -114,9 +128,31 @@ async def get_user_stats(
viewer: CurrentUser, viewer: CurrentUser,
db: DbSession, db: DbSession,
) -> ProfileStatsOut: ) -> 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="只能查看自己的统计数据") 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]) @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: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100), page_size: int = Query(50, ge=1, le=100),
) -> PageOut[CloudOut]: ) -> 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 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) 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: async def _build_like_stats(user_id: uuid.UUID, db: DbSession) -> LikeStatsOut:
result = await db.execute( user = await db.get(User, user_id)
select(User).options(joinedload(User.profile)).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user: if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
cloud_count = ( other_likes = func.count(CloudFavorite.id).filter(
await db.scalar(select(func.count()).select_from(Cloud).where(Cloud.user_id == user_id)) CloudFavorite.user_id != Cloud.user_id
or 0
) )
public_cloud_count = ( rows = await db.execute(
await db.scalar( select(
select(func.count()) Cloud.id,
.select_from(Cloud) Cloud.image_path,
.where( Cloud.thumbnail_path,
Cloud.user_id == user_id, Cloud.captured_at,
Cloud.status == "approved", Cloud.created_at,
Cloud.is_hidden.is_(False), 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 = ( photos = [
await db.scalar( LikeStatsPhotoOut(
select(func.count()).select_from(UserCollection).where(UserCollection.user_id == user_id) 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, user_id=user.id,
username=user.profile.username, username=user.username,
email=user.email, total_likes=total_likes,
created_at=user.created_at, photos=photos,
cloud_count=cloud_count,
public_cloud_count=public_cloud_count,
collection_count=collection_count,
) )
@@ -188,7 +225,8 @@ async def _list_profile_clouds(
page: int, page: int,
page_size: int, page_size: int,
) -> PageOut[CloudOut]: ) -> 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="用户不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
can_manage = bool(viewer_id and (viewer_id == user_id or viewer_role == "admin")) 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 total = await db.scalar(select(func.count()).select_from(Cloud).where(*filters)) or 0
result = await db.execute( result = await db.execute(
select(Cloud) select(Cloud)
.options(joinedload(Cloud.user).joinedload(User.profile), joinedload(Cloud.cloud_type)) .options(joinedload(Cloud.user), joinedload(Cloud.cloud_type))
.where(*filters) .where(*filters)
.order_by(Cloud.captured_at.desc().nulls_last(), Cloud.created_at.desc(), Cloud.id.desc()) .order_by(Cloud.captured_at.desc().nulls_last(), Cloud.created_at.desc(), Cloud.id.desc())
.offset((page - 1) * page_size) .offset((page - 1) * page_size)
+35 -33
View File
@@ -49,30 +49,19 @@ class ChangePasswordIn(BaseModel):
password: str = Field(min_length=8, max_length=128) 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): class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: uuid.UUID id: uuid.UUID
email: EmailStr email: EmailStr
email_verified: bool 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 created_at: datetime
@@ -81,12 +70,10 @@ class AuthOut(BaseModel):
token_type: Literal["bearer"] = "bearer" token_type: Literal["bearer"] = "bearer"
expires_in: int expires_in: int
user: UserOut user: UserOut
profile: ProfileOut
class MeOut(BaseModel): class MeOut(BaseModel):
user: UserOut user: UserOut
profile: ProfileOut
class ProfileUpdateIn(BaseModel): class ProfileUpdateIn(BaseModel):
@@ -98,6 +85,16 @@ class ProfileUpdateIn(BaseModel):
return value.strip() 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): class CloudTypeSummary(BaseModel):
id: int id: int
name: str name: str
@@ -138,6 +135,8 @@ class CloudOut(BaseModel):
cloud_type_name: str cloud_type_name: str
cloud_type_rarity: Rarity cloud_type_rarity: Rarity
username: str username: str
favorite_count: int = 0
is_favorited: bool = False
class CloudUpdateIn(BaseModel): class CloudUpdateIn(BaseModel):
@@ -228,7 +227,6 @@ class AdminStatsOut(BaseModel):
class AdminUserOut(BaseModel): class AdminUserOut(BaseModel):
user: UserOut user: UserOut
profile: ProfileOut
class AdminUserUpdateIn(BaseModel): class AdminUserUpdateIn(BaseModel):
@@ -269,19 +267,7 @@ class BatchUpdateOut(BaseModel):
updated: int 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): class FavoriteIn(BaseModel):
"""设置或取消对照片的点赞收藏。"""
favorited: bool favorited: bool
@@ -290,3 +276,19 @@ class FavoriteOut(BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
favorited: bool favorited: bool
favorite_count: int 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]
+6 -5
View File
@@ -5,7 +5,7 @@ from sqlalchemy.orm import selectinload
from app.config import settings from app.config import settings
from app.database import SessionLocal 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 from app.security import hash_password, utc_now
@@ -36,20 +36,21 @@ async def seed() -> None:
if settings.admin_email and settings.admin_password: if settings.admin_email and settings.admin_password:
email = settings.admin_email.strip().lower() email = settings.admin_email.strip().lower()
result = await db.execute( 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() admin = result.scalar_one_or_none()
if admin: if admin:
admin.profile.role = "admin" admin.role = "admin"
admin.profile.is_disabled = False admin.is_disabled = False
admin.email_verified_at = admin.email_verified_at or utc_now() admin.email_verified_at = admin.email_verified_at or utc_now()
else: else:
admin = User( admin = User(
email=email, email=email,
password_hash=hash_password(settings.admin_password), password_hash=hash_password(settings.admin_password),
email_verified_at=utc_now(), email_verified_at=utc_now(),
username=settings.admin_username,
role="admin",
) )
admin.profile = Profile(username=settings.admin_username, role="admin")
db.add(admin) db.add(admin)
await db.commit() await db.commit()
+20 -27
View File
@@ -1,12 +1,10 @@
from app.config import settings from app.config import settings
from app.models import Cloud, CloudType, Profile, User from app.models import Cloud, CloudType, User
from app.schemas import ( from app.schemas import (
CloudOut, CloudOut,
CloudOwnerOut, CloudOwnerOut,
CloudTypeOut, CloudTypeOut,
CloudTypeSummary, CloudTypeSummary,
ProfileOut,
PublicProfileOut,
UserOut, UserOut,
) )
@@ -17,31 +15,18 @@ def media_url(path: str | None) -> str | None:
return f"{settings.media_base_url}/{path.lstrip('/')}" 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: def user_out(user: User) -> UserOut:
return UserOut( return UserOut(
id=user.id, id=user.id,
email=user.email, email=user.email,
email_verified=user.email_verified_at is not None, 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, 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 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 "未知") type_name = cloud_type.name if cloud_type else (cloud.custom_cloud_type or "未知")
rarity = cloud_type.rarity if cloud_type else "common" rarity = cloud_type.rarity if cloud_type else "common"
return CloudOut( return CloudOut(
@@ -90,8 +81,10 @@ def cloud_out(cloud: Cloud, *, include_private: bool = True) -> CloudOut:
created_at=cloud.created_at, created_at=cloud.created_at,
updated_at=cloud.updated_at, updated_at=cloud.updated_at,
cloud_type=cloud_type_summary(cloud_type) if cloud_type else None, 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_name=type_name,
cloud_type_rarity=rarity, 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,
) )
+4 -4
View File
@@ -71,7 +71,7 @@ def test_create_user(client: TestClient) -> None:
assert r.status_code == 201 assert r.status_code == 201
body = r.json() body = r.json()
assert body["user"]["email_verified"] is True 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: 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.status_code == 201
assert r.json()["profile"]["role"] == "admin" assert r.json()["user"]["role"] == "admin"
def test_create_user_duplicate_email(client: TestClient) -> None: 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"}, json={"role": "admin"},
) )
assert r.status_code == 200 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: 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}, json={"is_disabled": True},
) )
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["profile"]["is_disabled"] is True assert r.json()["user"]["is_disabled"] is True
client.patch( client.patch(
f"/api/v1/admin/users/{info['user_id']}", f"/api/v1/admin/users/{info['user_id']}",
+1 -1
View File
@@ -264,7 +264,7 @@ def test_me(client: TestClient, monkeypatch) -> None:
assert r.status_code == 200 assert r.status_code == 200
body = r.json() body = r.json()
assert body["user"]["email"] == email assert body["user"]["email"] == email
assert body["profile"]["username"] == "我的信息" assert body["user"]["username"] == "我的信息"
def test_me_unauthenticated(client: TestClient) -> None: def test_me_unauthenticated(client: TestClient) -> None: