Files
Mplan e75833ee4b 重构表结构:合并 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
2026-07-28 23:48:57 +08:00

254 lines
8.2 KiB
Python

import math
import uuid
from typing import Annotated
from fastapi import APIRouter, File, HTTPException, Query, UploadFile, status
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, CloudFavorite, User, UserCollection
from app.schemas import (
CloudOut,
LikeStatsOut,
LikeStatsPhotoOut,
PageOut,
ProfileStatsOut,
ProfileUpdateIn,
UserOut,
)
from app.serializers import cloud_out, media_url, user_out
from app.services.storage import delete_files, save_avatar
router = APIRouter(prefix="/profiles", tags=["用户资料"])
# ---------------------------------------------------------------------------
# /me 路由(必须定义在 /{user_id} 之前,避免 "me" 被当作 UUID 解析)
# ---------------------------------------------------------------------------
@router.get("/me", response_model=UserOut)
async def get_my_profile(user: CurrentUser) -> UserOut:
return user_out(user)
@router.patch("/me", response_model=UserOut)
async def update_my_profile(
payload: ProfileUpdateIn,
user: CurrentUser,
db: DbSession,
) -> 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 user_out(user)
@router.post("/me/avatar", response_model=UserOut)
async def upload_avatar(
image: Annotated[UploadFile, File()],
user: CurrentUser,
db: DbSession,
) -> 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.avatar_path = avatar_path
await db.commit()
return user_out(user)
@router.delete("/me/avatar", response_model=UserOut)
async def delete_avatar(
user: CurrentUser,
db: DbSession,
) -> UserOut:
if not user.avatar_path:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="没有头像")
old_path = user.avatar_path
user.avatar_path = None
await db.commit()
await delete_files([old_path])
return user_out(user)
@router.get("/me/stats", response_model=ProfileStatsOut)
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])
async def get_my_clouds(
user: CurrentUser,
db: DbSession,
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.role, viewer_id=user.id, page=page, page_size=page_size)
# ---------------------------------------------------------------------------
# /{user_id} 路由
# ---------------------------------------------------------------------------
@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 user_out(user)
@router.get("/{user_id}/stats", response_model=ProfileStatsOut)
async def get_user_stats(
user_id: uuid.UUID,
viewer: CurrentUser,
db: DbSession,
) -> ProfileStatsOut:
if viewer.id != user_id and viewer.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只能查看自己的统计数据")
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])
async def get_profile_clouds(
user_id: uuid.UUID,
db: DbSession,
viewer: OptionalUser,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
) -> PageOut[CloudOut]:
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)
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
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="用户不存在")
other_likes = func.count(CloudFavorite.id).filter(
CloudFavorite.user_id != Cloud.user_id
)
rows = await db.execute(
select(
Cloud.id,
Cloud.image_path,
Cloud.thumbnail_path,
Cloud.captured_at,
Cloud.created_at,
other_likes.label("favorite_count"),
)
.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())
)
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,
)
for row in rows
]
total_likes = sum(p.favorite_count for p in photos)
return LikeStatsOut(
user_id=user.id,
username=user.username,
total_likes=total_likes,
photos=photos,
)
async def _list_profile_clouds(
user_id: uuid.UUID,
db: DbSession,
*,
viewer_role: str | None,
viewer_id: uuid.UUID | None,
page: int,
page_size: int,
) -> PageOut[CloudOut]:
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"))
filters = [Cloud.user_id == user_id]
if not can_manage:
filters.extend([Cloud.status == "approved", Cloud.is_hidden.is_(False)])
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(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)
.limit(page_size)
)
items = [cloud_out(item, include_private=can_manage) for item in result.scalars().unique().all()]
return PageOut(
items=items,
page=page,
page_size=page_size,
total=total,
total_pages=max(1, math.ceil(total / page_size)),
)