Files
opencloud-backend/app/routers/clouds.py
T
2026-07-18 20:32:51 +08:00

327 lines
13 KiB
Python

import math
import uuid
from datetime import datetime, timezone
from decimal import Decimal, ROUND_HALF_UP
from typing import Annotated, Literal
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, status
from sqlalchemy import func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.orm import joinedload
from app.deps import CurrentUser, DbSession, OptionalUser
from app.models import Cloud, CloudType, Profile, User, UserCollection
from app.schemas import (
BadgeOut,
BatchIdsIn,
CloudCreateOut,
CloudOut,
CloudUpdateIn,
DeleteResultOut,
PageOut,
)
from app.serializers import cloud_out
from app.services.storage import delete_files, save_cloud_image
router = APIRouter(prefix="/clouds", tags=["云图"])
def _cloud_options():
return (
joinedload(Cloud.user).joinedload(User.profile),
joinedload(Cloud.cloud_type),
)
def _normalize_text(value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None
def _blur_coordinate(value: float | None) -> Decimal | None:
if value is None:
return None
return Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def _normalize_datetime(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
def _normalize_required_datetime(value: datetime) -> datetime:
return _normalize_datetime(value) or value.replace(tzinfo=timezone.utc)
async def _require_cloud_type(db: DbSession, cloud_type_id: int) -> CloudType:
cloud_type = await db.get(CloudType, cloud_type_id)
if not cloud_type:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="云类型不存在")
return cloud_type
async def _get_cloud(db: DbSession, cloud_id: uuid.UUID) -> Cloud | None:
result = await db.execute(
select(Cloud).options(*_cloud_options()).where(Cloud.id == cloud_id)
)
return result.scalar_one_or_none()
@router.get("", response_model=PageOut[CloudOut])
async def list_gallery_clouds(
db: DbSession,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
type_id: int | None = Query(None, ge=1),
search: str | None = Query(None, max_length=80),
) -> PageOut[CloudOut]:
filters = [Cloud.status == "approved", Cloud.is_hidden.is_(False)]
if type_id is not None:
filters.append(Cloud.cloud_type_id == type_id)
data_query = select(Cloud).options(*_cloud_options())
count_query = select(func.count(Cloud.id))
term = _normalize_text(search)
if term and term.startswith("@"):
username = term[1:].strip()
if not username:
return PageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
data_query = data_query.join(Cloud.user).join(User.profile)
count_query = count_query.join(Cloud.user).join(User.profile)
filters.append(Profile.username.ilike(f"%{username}%"))
elif term:
data_query = data_query.outerjoin(Cloud.cloud_type)
count_query = count_query.outerjoin(Cloud.cloud_type)
filters.append(
or_(
CloudType.name.ilike(f"%{term}%"),
CloudType.name_en.ilike(f"%{term}%"),
Cloud.custom_cloud_type.ilike(f"%{term}%"),
)
)
total = await db.scalar(count_query.where(*filters)) or 0
result = await db.execute(
data_query.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, include_private=False) for item in result.scalars().unique().all()],
page=page,
page_size=page_size,
total=total,
total_pages=max(1, math.ceil(total / page_size)),
)
@router.get("/map", response_model=list[CloudOut])
async def list_map_clouds(
db: DbSession,
start: datetime,
end: datetime,
time_field: Literal["captured_at", "created_at"] = "captured_at",
limit: int = Query(1000, ge=1, le=1000),
) -> list[CloudOut]:
start = _normalize_required_datetime(start)
end = _normalize_required_datetime(end)
if start >= end:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="结束时间必须晚于开始时间")
field = Cloud.captured_at if time_field == "captured_at" else Cloud.created_at
result = await db.execute(
select(Cloud)
.options(*_cloud_options())
.where(
Cloud.status == "approved",
Cloud.is_hidden.is_(False),
Cloud.latitude.is_not(None),
Cloud.longitude.is_not(None),
field >= start,
field < end,
)
.order_by(field.asc(), Cloud.id.asc())
.limit(limit)
)
return [cloud_out(item, include_private=False) for item in result.scalars().unique().all()]
@router.get("/{cloud_id}", response_model=CloudOut)
async def get_cloud(cloud_id: uuid.UUID, db: DbSession, viewer: OptionalUser) -> CloudOut:
cloud = await _get_cloud(db, cloud_id)
if not cloud:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
can_manage = bool(viewer and (viewer.id == cloud.user_id or viewer.profile.role == "admin"))
if not can_manage and (cloud.status != "approved" or cloud.is_hidden):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
return cloud_out(cloud, include_private=can_manage)
@router.post("", response_model=CloudCreateOut, status_code=status.HTTP_201_CREATED)
async def create_cloud(
db: DbSession,
user: CurrentUser,
image: Annotated[UploadFile, File()],
cloud_type_id: Annotated[int | None, Form()] = None,
custom_cloud_type: Annotated[str | None, Form(max_length=80)] = None,
latitude: Annotated[float | None, Form(ge=-90, le=90)] = None,
longitude: Annotated[float | None, Form(ge=-180, le=180)] = None,
location_name: Annotated[str | None, Form(max_length=120)] = None,
description: Annotated[str | None, Form(max_length=2000)] = None,
captured_at: Annotated[datetime | None, Form()] = None,
is_hidden: Annotated[bool, Form()] = False,
) -> CloudCreateOut:
custom_cloud_type = _normalize_text(custom_cloud_type)
if (cloud_type_id is None) == (custom_cloud_type is None):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="必须选择一个云类型,或填写自定义云类型",
)
if (latitude is None) != (longitude is None):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="经纬度必须同时填写")
cloud_type = await _require_cloud_type(db, cloud_type_id) if cloud_type_id else None
image_path, thumbnail_path = await save_cloud_image(image, user.id)
cloud = Cloud(
user_id=user.id,
cloud_type_id=cloud_type_id,
custom_cloud_type=custom_cloud_type,
image_path=image_path,
thumbnail_path=thumbnail_path,
latitude=_blur_coordinate(latitude),
longitude=_blur_coordinate(longitude),
location_name=_normalize_text(location_name),
description=_normalize_text(description),
captured_at=_normalize_datetime(captured_at) or datetime.now(timezone.utc),
status="pending",
is_hidden=is_hidden,
)
db.add(cloud)
unlocked_badge: BadgeOut | None = None
try:
await db.flush()
if cloud_type:
insert_factory = pg_insert if db.bind and db.bind.dialect.name == "postgresql" else sqlite_insert
insert_result = await db.execute(
insert_factory(UserCollection)
.values(
user_id=user.id,
cloud_type_id=cloud_type.id,
first_cloud_id=cloud.id,
)
.on_conflict_do_nothing(
index_elements=[UserCollection.user_id, UserCollection.cloud_type_id]
)
.returning(UserCollection.unlocked_at)
)
unlocked_at = insert_result.scalar_one_or_none()
if unlocked_at:
unlocked_badge = BadgeOut(
cloud_type_id=cloud_type.id,
cloud_name=cloud_type.name,
cloud_name_en=cloud_type.name_en,
rarity=cloud_type.rarity,
unlocked_at=unlocked_at,
)
await db.commit()
except Exception:
await db.rollback()
await delete_files([image_path, thumbnail_path])
raise
saved = await _get_cloud(db, cloud.id)
if not saved:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="图片记录创建失败")
return CloudCreateOut(cloud=cloud_out(saved), unlocked_badge=unlocked_badge)
@router.patch("/{cloud_id}", response_model=CloudOut)
async def update_cloud(
cloud_id: uuid.UUID,
payload: CloudUpdateIn,
db: DbSession,
user: CurrentUser,
) -> CloudOut:
cloud = await _get_cloud(db, cloud_id)
if not cloud:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
if cloud.user_id != user.id and user.profile.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改其他用户的图片")
changes = payload.model_dump(exclude_unset=True)
final_type_id = changes.get("cloud_type_id", cloud.cloud_type_id)
final_custom = _normalize_text(changes.get("custom_cloud_type", cloud.custom_cloud_type))
if "cloud_type_id" in changes and changes["cloud_type_id"] is not None and "custom_cloud_type" not in changes:
final_custom = None
if "custom_cloud_type" in changes and final_custom is not None and "cloud_type_id" not in changes:
final_type_id = None
if (final_type_id is None) == (final_custom is None):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="必须选择一个云类型,或填写自定义云类型",
)
if final_type_id is not None:
await _require_cloud_type(db, final_type_id)
if "cloud_type_id" in changes or "custom_cloud_type" in changes:
cloud.cloud_type_id = final_type_id
cloud.custom_cloud_type = final_custom
if "latitude" in changes:
cloud.latitude = _blur_coordinate(changes["latitude"])
cloud.longitude = _blur_coordinate(changes["longitude"])
if "location_name" in changes:
cloud.location_name = _normalize_text(changes["location_name"])
if "description" in changes:
cloud.description = _normalize_text(changes["description"])
if "captured_at" in changes:
cloud.captured_at = _normalize_datetime(changes["captured_at"])
if "is_hidden" in changes:
cloud.is_hidden = changes["is_hidden"]
await db.commit()
updated = await _get_cloud(db, cloud.id)
if not updated:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
return cloud_out(updated)
@router.delete("/{cloud_id}", response_model=DeleteResultOut)
async def delete_cloud(cloud_id: uuid.UUID, db: DbSession, user: CurrentUser) -> DeleteResultOut:
cloud = await _get_cloud(db, cloud_id)
if not cloud:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
if cloud.user_id != user.id and user.profile.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能删除其他用户的图片")
paths = [cloud.image_path, cloud.thumbnail_path]
await db.delete(cloud)
await db.commit()
await delete_files(paths)
return DeleteResultOut(deleted=1)
@router.post("/batch-delete", response_model=DeleteResultOut)
async def batch_delete_clouds(
payload: BatchIdsIn,
db: DbSession,
user: CurrentUser,
) -> DeleteResultOut:
result = await db.execute(
select(Cloud).where(Cloud.id.in_(payload.ids), Cloud.user_id == user.id)
)
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))