455 lines
16 KiB
Python
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
|