Files
opencloud-backend/app/routers/admin.py
T
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

296 lines
9.6 KiB
Python

import math
import uuid
from datetime import datetime, timezone
from typing import Literal
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
from app.deps import AdminUser, DbSession
from app.models import Cloud, User
from app.schemas import (
AdminCloudStatusIn,
AdminCloudVisibilityIn,
AdminCreateUserIn,
AdminStatsOut,
AdminUserOut,
AdminUserUpdateIn,
BatchIdsIn,
BatchUpdateOut,
CloudOut,
DeleteResultOut,
PageOut,
)
from app.security import hash_password, utc_now
from app.serializers import cloud_out, user_out
from app.services.storage import delete_files
router = APIRouter(prefix="/admin", tags=["管理后台"])
@router.get("/stats", response_model=AdminStatsOut)
async def get_stats(_: AdminUser, db: DbSession) -> AdminStatsOut:
local_tz = ZoneInfo("Asia/Shanghai")
local_now = datetime.now(local_tz)
today_start = local_now.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc)
users = await db.scalar(select(func.count()).select_from(User)) or 0
row = (
await db.execute(
select(
func.count(Cloud.id).label("images"),
func.sum(case((Cloud.created_at >= today_start, 1), else_=0)).label("today"),
func.sum(case((Cloud.status == "pending", 1), else_=0)).label("pending"),
func.sum(case((Cloud.status == "approved", 1), else_=0)).label("approved"),
func.sum(case((Cloud.status == "rejected", 1), else_=0)).label("rejected"),
func.sum(case((Cloud.is_hidden.is_(True), 1), else_=0)).label("hidden"),
)
)
).one()
return AdminStatsOut(
users=users,
images=row.images or 0,
today_uploads=row.today or 0,
pending=row.pending or 0,
approved=row.approved or 0,
rejected=row.rejected or 0,
hidden=row.hidden or 0,
)
@router.get("/users", response_model=PageOut[AdminUserOut])
async def list_users(
_: AdminUser,
db: DbSession,
page: int = Query(1, ge=1),
page_size: int = Query(100, ge=1, le=100),
) -> PageOut[AdminUserOut]:
total = await db.scalar(select(func.count()).select_from(User)) or 0
result = await db.execute(
select(User)
.order_by(User.created_at.desc(), User.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
items = [AdminUserOut(user=user_out(user)) for user in result.scalars()]
return PageOut(
items=items,
page=page,
page_size=page_size,
total=total,
total_pages=max(1, math.ceil(total / page_size)),
)
@router.post("/users", response_model=AdminUserOut, status_code=status.HTTP_201_CREATED)
async def create_user(
payload: AdminCreateUserIn,
_: AdminUser,
db: DbSession,
) -> AdminUserOut:
email = str(payload.email).strip().lower()
user = User(
email=email,
password_hash=hash_password(payload.password),
email_verified_at=utc_now(),
username=payload.username,
role=payload.role,
)
db.add(user)
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
message = str(exc.orig).lower()
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)
return AdminUserOut(user=user_out(user))
@router.patch("/users/{user_id}", response_model=AdminUserOut)
async def update_user(
user_id: uuid.UUID,
payload: AdminUserUpdateIn,
admin: AdminUser,
db: DbSession,
) -> AdminUserOut:
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 (
payload.is_disabled is True or (payload.role is not None and payload.role != "admin")
):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能禁用自己或移除自己的管理员权限")
if payload.role is not None:
user.role = payload.role
if payload.is_disabled is not None:
user.is_disabled = payload.is_disabled
await db.commit()
return AdminUserOut(user=user_out(user))
@router.get("/clouds", response_model=PageOut[CloudOut])
async def list_admin_clouds(
_: AdminUser,
db: DbSession,
page: int = Query(1, ge=1),
page_size: int = Query(100, ge=1, le=120),
cloud_status: Literal["pending", "approved", "rejected"] | None = Query(None, alias="status"),
is_hidden: bool | None = None,
) -> PageOut[CloudOut]:
filters = []
if cloud_status:
filters.append(Cloud.status == cloud_status)
if is_hidden is not None:
filters.append(Cloud.is_hidden == is_hidden)
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.created_at.desc(), Cloud.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
return PageOut(
items=[cloud_out(item) for item in result.scalars().unique().all()],
page=page,
page_size=page_size,
total=total,
total_pages=max(1, math.ceil(total / page_size)),
)
async def _require_all_cloud_ids(db: DbSession, ids: list[uuid.UUID]) -> None:
count = await db.scalar(select(func.count()).select_from(Cloud).where(Cloud.id.in_(ids))) or 0
if count != len(ids):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部分图片不存在")
@router.patch("/clouds/status", response_model=BatchUpdateOut)
async def update_cloud_status(
payload: AdminCloudStatusIn,
_: AdminUser,
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))
.values(status=payload.status, updated_at=func.now())
)
await db.commit()
return BatchUpdateOut(updated=len(payload.ids))
@router.patch("/clouds/visibility", response_model=BatchUpdateOut)
async def update_cloud_visibility(
payload: AdminCloudVisibilityIn,
_: AdminUser,
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))
.values(is_hidden=payload.is_hidden, updated_at=func.now())
)
await db.commit()
return BatchUpdateOut(updated=len(payload.ids))
@router.post("/clouds/batch-delete", response_model=DeleteResultOut)
async def delete_admin_clouds(
payload: BatchIdsIn,
_: AdminUser,
db: DbSession,
) -> DeleteResultOut:
result = await db.execute(select(Cloud).where(Cloud.id.in_(payload.ids)))
clouds = result.scalars().all()
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))