first commit
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
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))
|
||||
Reference in New Issue
Block a user