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