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