Files
CosScene/server/app/api/v1/endpoints/spots.py
T
2026-05-09 16:40:29 +08:00

455 lines
16 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query, status
from geoalchemy2.functions import ST_DWithin, ST_MakePoint, ST_SetSRID, ST_X, ST_Y
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_active_user, get_db, get_optional_current_user
from app.models.favorite import Favorite
from app.models.spot import Spot, SpotImage
from app.models.tag import SpotTag, Tag
from app.models.user import User
from app.schemas.common import PageResponse
from app.schemas.spot import (
SpotBrief,
SpotCreate,
SpotDetail,
SpotImageCreate,
SpotImageOut,
SpotUpdate,
)
from app.services.audit_service import log_action
from app.services.notification_service import send_notification
from app.services.point_service import grant_points
class RejectPayload(BaseModel):
reason: str
router = APIRouter()
def _spot_to_brief(spot: Spot, lng: float | None = None, lat: float | None = None) -> SpotBrief:
cover = next((img for img in spot.images if img.is_cover), None)
if cover is None and spot.images:
cover = spot.images[0]
return SpotBrief(
id=spot.id,
title=spot.title,
city=spot.city,
longitude=lng if lng is not None else spot.longitude,
latitude=lat if lat is not None else spot.latitude,
cover_image_url=cover.image_url if cover else None,
audit_status=spot.audit_status,
avg_rating=spot.avg_rating,
favorite_count=spot.favorite_count or 0,
is_free=spot.is_free,
price_min=float(spot.price_min) if spot.price_min is not None else None,
price_max=float(spot.price_max) if spot.price_max is not None else None,
created_at=spot.created_at,
)
@router.post("/", response_model=SpotBrief, status_code=status.HTTP_201_CREATED)
async def create_spot(
payload: SpotCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
from app.services.content_safety import check_text
for field_val in [payload.title, payload.description]:
if field_val:
result = check_text(field_val)
if not result["safe"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"内容包含敏感词:{''.join(result['matched'][:3])}",
)
point = func.ST_SetSRID(func.ST_MakePoint(payload.longitude, payload.latitude), 4326)
spot = Spot(
title=payload.title,
city=payload.city,
location=point,
description=payload.description,
transport=payload.transport,
best_time=payload.best_time,
difficulty=payload.difficulty,
is_free=payload.is_free,
price_min=payload.price_min if not payload.is_free else None,
price_max=payload.price_max if not payload.is_free else None,
audit_status="pending",
creator_id=current_user.id,
)
db.add(spot)
await db.flush()
for idx, url in enumerate(payload.image_urls):
img = SpotImage(
spot_id=spot.id,
image_url=url,
is_cover=(idx == 0),
sort_order=idx,
)
db.add(img)
if payload.tag_ids:
tag_result = await db.execute(
select(Tag).where(Tag.id.in_(payload.tag_ids), Tag.is_active.is_(True))
)
valid_tags = tag_result.scalars().all()
for tag in valid_tags:
db.add(SpotTag(spot_id=spot.id, tag_id=tag.id))
tag.usage_count = (tag.usage_count or 0) + 1
await db.commit()
await db.refresh(spot)
return _spot_to_brief(spot)
@router.get("/", response_model=PageResponse[SpotBrief])
async def list_spots(
city: str | None = None,
tag_id: int | None = None,
creator_id: int | None = None,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
sort_by: str = Query(default="created_at"),
current_user: User | None = Depends(get_optional_current_user),
db: AsyncSession = Depends(get_db),
):
lng_col = ST_X(Spot.location).label("lng")
lat_col = ST_Y(Spot.location).label("lat")
query = select(Spot, lng_col, lat_col)
count_query = select(func.count(Spot.id))
is_admin = current_user and current_user.role in ("admin", "moderator")
if not is_admin:
query = query.where(Spot.audit_status == "approved")
count_query = count_query.where(Spot.audit_status == "approved")
if creator_id is not None:
query = query.where(Spot.creator_id == creator_id)
count_query = count_query.where(Spot.creator_id == creator_id)
if city:
city_filter = Spot.city.ilike(f"%{city}%")
query = query.where(city_filter)
count_query = count_query.where(city_filter)
if tag_id is not None:
tag_filter = select(SpotTag.spot_id).where(SpotTag.tag_id == tag_id).scalar_subquery()
query = query.where(Spot.id.in_(tag_filter))
count_query = count_query.where(Spot.id.in_(tag_filter))
sort_column = getattr(Spot, sort_by, Spot.created_at)
query = query.order_by(sort_column.desc())
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(query.offset(offset).limit(page_size))
rows = result.all()
return PageResponse(
total=total,
items=[_spot_to_brief(row[0], lng=row[1], lat=row[2]) for row in rows],
)
@router.get("/mine", response_model=PageResponse[SpotBrief])
async def get_my_spots(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
audit_status: str | None = None,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
lng_col = ST_X(Spot.location).label("lng")
lat_col = ST_Y(Spot.location).label("lat")
base = select(Spot, lng_col, lat_col).where(Spot.creator_id == current_user.id)
count_base = select(func.count(Spot.id)).where(Spot.creator_id == current_user.id)
if audit_status:
base = base.where(Spot.audit_status == audit_status)
count_base = count_base.where(Spot.audit_status == audit_status)
total_result = await db.execute(count_base)
total = total_result.scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(base.order_by(Spot.created_at.desc()).offset(offset).limit(page_size))
rows = result.all()
return PageResponse(total=total, items=[_spot_to_brief(r[0], lng=r[1], lat=r[2]) for r in rows])
@router.get("/pending", response_model=PageResponse[SpotBrief])
async def get_pending_spots(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
if current_user.role not in ("admin", "moderator"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
count_result = await db.execute(
select(func.count(Spot.id)).where(Spot.audit_status == "pending")
)
total = count_result.scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(
select(Spot, ST_X(Spot.location).label("lng"), ST_Y(Spot.location).label("lat"))
.where(Spot.audit_status == "pending")
.order_by(Spot.created_at.asc())
.offset(offset)
.limit(page_size)
)
rows = result.all()
return PageResponse(total=total, items=[_spot_to_brief(r[0], lng=r[1], lat=r[2]) for r in rows])
@router.get("/nearby", response_model=list[SpotBrief])
async def get_nearby_spots(
longitude: float = Query(...),
latitude: float = Query(...),
radius_km: float = Query(default=5.0),
db: AsyncSession = Depends(get_db),
):
# ST_DWithin with geography uses metres
point = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)
query = (
select(Spot, ST_X(Spot.location).label("lng"), ST_Y(Spot.location).label("lat"))
.where(Spot.audit_status == "approved")
.where(
ST_DWithin(
Spot.location,
func.ST_GeogFromWKB(func.ST_AsBinary(point)),
radius_km * 1000,
)
)
.limit(50)
)
result = await db.execute(query)
rows = result.all()
return [_spot_to_brief(r[0], lng=r[1], lat=r[2]) for r in rows]
@router.get("/{spot_id}", response_model=SpotDetail)
async def get_spot(
spot_id: int,
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(get_optional_current_user),
):
result = await db.execute(
select(Spot, ST_X(Spot.location).label("lng"), ST_Y(Spot.location).label("lat"))
.where(Spot.id == spot_id)
)
row = result.one_or_none()
if not row:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Spot not found")
spot, lng, lat = row
is_favorited = False
if current_user:
fav_result = await db.execute(
select(Favorite).where(
Favorite.user_id == current_user.id, Favorite.spot_id == spot_id
)
)
is_favorited = fav_result.scalar_one_or_none() is not None
cover = next((img for img in spot.images if img.is_cover), None)
if cover is None and spot.images:
cover = spot.images[0]
return SpotDetail(
id=spot.id,
title=spot.title,
city=spot.city,
longitude=lng,
latitude=lat,
cover_image_url=cover.image_url if cover else None,
audit_status=spot.audit_status,
avg_rating=spot.avg_rating,
is_free=spot.is_free,
price_min=float(spot.price_min) if spot.price_min is not None else None,
price_max=float(spot.price_max) if spot.price_max is not None else None,
created_at=spot.created_at,
description=spot.description,
transport=spot.transport,
best_time=spot.best_time,
difficulty=spot.difficulty,
creator=spot.creator,
images=[
SpotImageOut.model_validate(img) for img in spot.images
],
tags=spot.tags,
rating_count=spot.rating_count,
is_favorited=is_favorited,
)
@router.put("/{spot_id}", response_model=SpotBrief)
async def update_spot(
spot_id: int,
payload: SpotUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Spot).where(Spot.id == spot_id))
spot = result.scalar_one_or_none()
if not spot:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Spot not found")
is_admin = current_user.role in ("admin", "moderator")
if spot.creator_id != current_user.id and not is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
update_data = payload.model_dump(exclude_unset=True)
tag_ids = update_data.pop("tag_ids", None)
if "longitude" in update_data or "latitude" in update_data:
lng = update_data.pop("longitude", spot.longitude)
lat = update_data.pop("latitude", spot.latitude)
spot.location = func.ST_SetSRID(func.ST_MakePoint(lng, lat), 4326)
for field, value in update_data.items():
setattr(spot, field, value)
if tag_ids is not None:
await db.execute(
select(SpotTag).where(SpotTag.spot_id == spot_id)
)
from sqlalchemy import delete as sa_delete
await db.execute(sa_delete(SpotTag).where(SpotTag.spot_id == spot_id))
if tag_ids:
tag_result = await db.execute(
select(Tag).where(Tag.id.in_(tag_ids), Tag.is_active.is_(True))
)
valid_tags = tag_result.scalars().all()
for tag in valid_tags:
db.add(SpotTag(spot_id=spot.id, tag_id=tag.id))
db.add(spot)
await db.commit()
await db.refresh(spot)
return _spot_to_brief(spot)
@router.delete("/{spot_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_spot(
spot_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Spot).where(Spot.id == spot_id))
spot = result.scalar_one_or_none()
if not spot:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Spot not found")
is_admin = current_user.role in ("admin", "moderator")
if spot.creator_id != current_user.id and not is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
# Soft delete via audit_status
spot.audit_status = "deleted"
db.add(spot)
await db.commit()
@router.post("/{spot_id}/approve")
async def approve_spot(
spot_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
if current_user.role not in ("admin", "moderator"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
result = await db.execute(select(Spot).where(Spot.id == spot_id))
spot = result.scalar_one_or_none()
if not spot:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Spot not found")
spot.audit_status = "approved"
spot.reject_reason = None
db.add(spot)
await grant_points(db, spot.creator_id, 10, "地点审核通过", "spot_approved", spot.id)
await log_action(db, current_user.id, "spot.approve", "spot", spot.id)
await send_notification(
db, spot.creator_id, "audit",
f"您投稿的「{spot.title}」已通过审核",
content="恭喜!您的取景地投稿已通过审核,现已公开展示。",
ref_type="spot", ref_id=spot.id,
)
await db.commit()
return {"code": 0, "message": "approved"}
@router.post("/{spot_id}/reject")
async def reject_spot(
spot_id: int,
payload: RejectPayload,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
if current_user.role not in ("admin", "moderator"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
result = await db.execute(select(Spot).where(Spot.id == spot_id))
spot = result.scalar_one_or_none()
if not spot:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Spot not found")
spot.audit_status = "rejected"
spot.reject_reason = payload.reason
db.add(spot)
await log_action(
db, current_user.id, "spot.reject", "spot", spot.id,
detail={"reason": payload.reason},
)
await send_notification(
db, spot.creator_id, "audit",
f"您投稿的「{spot.title}」未通过审核",
content=f"原因:{payload.reason}",
ref_type="spot", ref_id=spot.id,
)
await db.commit()
return {"code": 0, "message": "rejected"}
@router.post("/{spot_id}/images", response_model=SpotImageOut, status_code=status.HTTP_201_CREATED)
async def add_spot_image(
spot_id: int,
payload: SpotImageCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Spot).where(Spot.id == spot_id))
spot = result.scalar_one_or_none()
if not spot:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Spot not found")
is_admin = current_user.role in ("admin", "moderator")
if spot.creator_id != current_user.id and not is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
image = SpotImage(
spot_id=spot_id,
image_url=payload.image_url,
is_cover=payload.is_cover,
sort_order=payload.sort_order,
)
db.add(image)
await db.commit()
await db.refresh(image)
return image