重构表结构:合并 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:
+106
-68
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user