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