- 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
389 lines
14 KiB
Python
389 lines
14 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, update
|
|
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, CloudFavorite, CloudType, 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(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)
|
|
count_query = count_query.join(Cloud.user)
|
|
filters.append(User.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.role == "admin"))
|
|
if not can_manage and (cloud.status != "approved" or cloud.is_hidden):
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
|
|
|
favorite_count = cloud.favorite_count
|
|
is_favorited = False
|
|
if viewer:
|
|
fav_id = await db.scalar(
|
|
select(CloudFavorite.id).where(
|
|
CloudFavorite.user_id == viewer.id,
|
|
CloudFavorite.cloud_id == cloud_id,
|
|
)
|
|
)
|
|
is_favorited = fav_id is not None
|
|
|
|
return cloud_out(cloud, include_private=can_manage, favorite_count=favorite_count, is_favorited=is_favorited)
|
|
|
|
|
|
@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()
|
|
|
|
await db.execute(
|
|
update(User)
|
|
.where(User.id == user.id)
|
|
.values(cloud_count=User.cloud_count + 1)
|
|
)
|
|
|
|
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:
|
|
await db.execute(
|
|
update(User)
|
|
.where(User.id == user.id)
|
|
.values(collection_count=User.collection_count + 1)
|
|
)
|
|
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.role != "admin":
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改其他用户的图片")
|
|
|
|
previous_public = cloud.status == "approved" and not cloud.is_hidden
|
|
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.flush()
|
|
new_public = cloud.status == "approved" and not cloud.is_hidden
|
|
if previous_public != new_public:
|
|
delta = 1 if new_public else -1
|
|
await db.execute(
|
|
update(User)
|
|
.where(User.id == cloud.user_id)
|
|
.values(public_cloud_count=User.public_cloud_count + delta)
|
|
)
|
|
|
|
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.role != "admin":
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能删除其他用户的图片")
|
|
paths = [cloud.image_path, cloud.thumbnail_path]
|
|
|
|
was_public = cloud.status == "approved" and not cloud.is_hidden
|
|
await db.delete(cloud)
|
|
|
|
await db.execute(
|
|
update(User)
|
|
.where(User.id == cloud.user_id)
|
|
.values(cloud_count=User.cloud_count - 1)
|
|
)
|
|
if was_public:
|
|
await db.execute(
|
|
update(User)
|
|
.where(User.id == cloud.user_id)
|
|
.values(public_cloud_count=User.public_cloud_count - 1)
|
|
)
|
|
|
|
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="部分图片不存在或不属于当前用户")
|
|
|
|
public_count = sum(1 for c in clouds if c.status == "approved" and not c.is_hidden)
|
|
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.execute(
|
|
update(User)
|
|
.where(User.id == user.id)
|
|
.values(
|
|
cloud_count=User.cloud_count - len(clouds),
|
|
public_cloud_count=User.public_cloud_count - public_count,
|
|
)
|
|
)
|
|
|
|
await db.commit()
|
|
await delete_files(paths)
|
|
return DeleteResultOut(deleted=len(clouds))
|