重构表结构:合并 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.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
+11 -52
View File
@@ -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)
+86 -16
View File
@@ -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))
+53 -42
View File
@@ -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),
)
+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
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)
+73 -11
View File
@@ -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))
+26 -22
View File
@@ -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(
+106 -68
View File
@@ -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)
+35 -33
View File
@@ -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]
+6 -5
View File
@@ -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()
+20 -27
View File
@@ -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,
)
+4 -4
View File
@@ -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']}",
+1 -1
View File
@@ -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: