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.orm import joinedload, selectinload from app.deps import AdminUser, DbSession from app.models import Cloud, Profile, User from app.schemas import ( AdminCloudStatusIn, AdminCloudVisibilityIn, AdminStatsOut, AdminUserOut, AdminUserUpdateIn, BatchIdsIn, BatchUpdateOut, CloudOut, DeleteResultOut, PageOut, ) from app.serializers import cloud_out, profile_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) .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()] return PageOut( items=items, page=page, page_size=page_size, total=total, total_pages=max(1, math.ceil(total / page_size)), ) @router.patch("/users/{user_id}", response_model=AdminUserOut) async def update_user( user_id: uuid.UUID, payload: AdminUserUpdateIn, 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() 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.profile.role = payload.role if payload.is_disabled is not None: user.profile.is_disabled = payload.is_disabled await db.commit() return AdminUserOut(user=user_out(user), profile=profile_out(user.profile)) @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(User.profile), 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) 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) 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)] for cloud in clouds: await db.delete(cloud) await db.commit() await delete_files(paths) return DeleteResultOut(deleted=len(clouds))