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))