- 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
296 lines
9.6 KiB
Python
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))
|