重构表结构:合并 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
+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)