Initial project commit

This commit is contained in:
2026-05-09 16:40:29 +08:00
commit 02b0259a9e
267 changed files with 54891 additions and 0 deletions
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db
from app.models.app_nav_config import AppNavConfig
from app.schemas.app_nav_config import AppNavConfigOut
router = APIRouter()
@router.get("/nav", response_model=list[AppNavConfigOut])
async def list_nav_config(
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(AppNavConfig)
.where(AppNavConfig.is_active.is_(True))
.order_by(AppNavConfig.sort_order.asc(), AppNavConfig.id.asc())
)
return result.scalars().all()
+135
View File
@@ -0,0 +1,135 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db
from app.core.rate_limit import RateLimiter
from app.core.security import (
create_access_token,
create_refresh_token,
decode_token,
get_password_hash,
verify_password,
)
from app.models.user import User
from app.schemas.user import (
RefreshTokenRequest,
TokenResponse,
UserRegister,
)
router = APIRouter()
@router.post(
"/register",
response_model=TokenResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(RateLimiter(times=5, seconds=60))],
)
async def register(payload: UserRegister, db: AsyncSession = Depends(get_db)):
if not payload.phone and not payload.email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Phone or email is required",
)
filters = []
if payload.phone:
filters.append(User.phone == payload.phone)
if payload.email:
filters.append(User.email == payload.email)
result = await db.execute(select(User).where(or_(*filters)))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Phone or email already registered",
)
user = User(
phone=payload.phone,
email=payload.email,
password_hash=get_password_hash(payload.password),
nickname=payload.nickname,
city=payload.city,
identity=payload.identity or "both",
)
db.add(user)
await db.commit()
await db.refresh(user)
return TokenResponse(
access_token=create_access_token(user.id),
refresh_token=create_refresh_token(user.id),
)
@router.post(
"/login",
response_model=TokenResponse,
dependencies=[Depends(RateLimiter(times=10, seconds=60))],
)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(User).where(
or_(User.phone == form_data.username, User.email == form_data.username)
)
)
user = result.scalar_one_or_none()
if not user or not verify_password(form_data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect account or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is disabled",
)
return TokenResponse(
access_token=create_access_token(user.id),
refresh_token=create_refresh_token(user.id),
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(
payload: RefreshTokenRequest,
db: AsyncSession = Depends(get_db),
):
try:
data = decode_token(payload.refresh_token)
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
if data.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type",
)
user_id = data.get("sub")
result = await db.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
)
return TokenResponse(
access_token=create_access_token(user.id),
refresh_token=create_refresh_token(user.id),
)
+145
View File
@@ -0,0 +1,145 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_active_user, get_db
from app.models.comment import Comment
from app.models.report import Report
from app.models.spot import Spot
from app.models.user import User
from app.schemas.comment import CommentCreate, CommentOut, ReportCreate
from app.schemas.common import PageResponse
router = APIRouter()
@router.post("/spots/{spot_id}/comments", response_model=CommentOut, status_code=status.HTTP_201_CREATED)
async def create_comment(
spot_id: int,
payload: CommentCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
from app.services.content_safety import check_text
safety = check_text(payload.content)
if not safety["safe"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"评论包含敏感词:{''.join(safety['matched'][:3])}",
)
result = await db.execute(select(Spot).where(Spot.id == spot_id))
if not result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Spot not found")
if payload.parent_id is not None:
parent_result = await db.execute(
select(Comment).where(Comment.id == payload.parent_id, Comment.spot_id == spot_id)
)
if not parent_result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Parent comment not found")
comment = Comment(
spot_id=spot_id,
user_id=current_user.id,
parent_id=payload.parent_id,
content=payload.content,
)
db.add(comment)
await db.commit()
await db.refresh(comment)
return comment
@router.get("/spots/{spot_id}/comments", response_model=PageResponse[CommentOut])
async def list_comments(
spot_id: int,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
base_filter = (
Comment.spot_id == spot_id,
Comment.parent_id.is_(None),
Comment.audit_status == "approved",
)
count_result = await db.execute(select(func.count(Comment.id)).where(*base_filter))
total = count_result.scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(
select(Comment)
.where(*base_filter)
.order_by(Comment.created_at.desc())
.offset(offset)
.limit(page_size)
)
top_comments = result.scalars().all()
if top_comments:
top_ids = [c.id for c in top_comments]
replies_result = await db.execute(
select(Comment)
.where(
Comment.parent_id.in_(top_ids),
Comment.audit_status == "approved",
)
.order_by(Comment.created_at.asc())
)
all_replies = replies_result.scalars().all()
replies_map: dict[int, list] = {}
for r in all_replies:
replies_map.setdefault(r.parent_id, []).append(r)
else:
replies_map = {}
items = []
for c in top_comments:
out = CommentOut.model_validate(c)
out.replies = [CommentOut.model_validate(r) for r in replies_map.get(c.id, [])]
items.append(out)
return PageResponse(total=total, items=items)
@router.delete("/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_comment(
comment_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Comment).where(Comment.id == comment_id))
comment = result.scalar_one_or_none()
if not comment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
is_admin = current_user.role in ("admin", "moderator")
if comment.user_id != current_user.id and not is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
await db.delete(comment)
await db.commit()
@router.post("/comments/{comment_id}/report", status_code=status.HTTP_201_CREATED)
async def report_comment(
comment_id: int,
payload: ReportCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Comment).where(Comment.id == comment_id))
if not result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
report = Report(
reporter_id=current_user.id,
target_type="comment",
target_id=comment_id,
reason=payload.reason,
)
db.add(report)
await db.commit()
return {"code": 0, "message": "Report submitted"}
@@ -0,0 +1,83 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_active_user, get_db
from app.models.correction import Correction
from app.models.spot import Spot
from app.models.user import User
from app.schemas.common import PageResponse
from app.schemas.correction import CorrectionCreate, CorrectionOut
router = APIRouter()
FIELD_LABELS = {
"title": "地点名称",
"city": "所在城市",
"description": "地点介绍",
"transport": "交通方式",
"best_time": "最佳拍摄时间",
"difficulty": "路径难度",
"price": "收费信息",
}
@router.post(
"/spots/{spot_id}/corrections",
response_model=CorrectionOut,
status_code=status.HTTP_201_CREATED,
)
async def create_correction(
spot_id: int,
payload: CorrectionCreate,
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")
if spot.creator_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot submit correction for your own spot",
)
correction = Correction(
spot_id=spot_id,
user_id=current_user.id,
field_name=payload.field_name,
suggested_value=payload.suggested_value,
reason=payload.reason,
)
db.add(correction)
await db.commit()
await db.refresh(correction)
return correction
@router.get(
"/spots/{spot_id}/corrections",
response_model=PageResponse[CorrectionOut],
)
async def list_corrections(
spot_id: int,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
base = Correction.spot_id == spot_id
count_result = await db.execute(select(func.count(Correction.id)).where(base))
total = count_result.scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(
select(Correction)
.where(base)
.order_by(Correction.created_at.desc())
.offset(offset)
.limit(page_size)
)
items = result.scalars().all()
return PageResponse(total=total, items=[CorrectionOut.model_validate(c) for c in items])
+388
View File
@@ -0,0 +1,388 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
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.event import Event, EventPhoto, EventRegistration
from app.models.user import User
from app.schemas.common import PageResponse
from app.schemas.event import (
EventBrief,
EventCreate,
EventDetail,
EventPhotoCreate,
EventPhotoOut,
EventUpdate,
RegistrationOut,
)
from app.services.notification_service import send_notification
router = APIRouter()
def _to_brief(ev: Event) -> EventBrief:
return EventBrief(
id=ev.id,
title=ev.title,
city=ev.city,
cover_url=ev.cover_url,
location_name=ev.location_name,
start_time=ev.start_time,
end_time=ev.end_time,
status=ev.status,
audit_status=ev.audit_status,
creator=ev.creator,
registration_count=ev.registration_count,
max_participants=ev.max_participants,
created_at=ev.created_at,
)
# --- Event CRUD ---
@router.post("/", response_model=EventBrief, status_code=status.HTTP_201_CREATED)
async def create_event(
payload: EventCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
ev = Event(
creator_id=current_user.id,
title=payload.title,
city=payload.city,
description=payload.description,
cover_url=payload.cover_url,
location_name=payload.location_name,
start_time=payload.start_time,
end_time=payload.end_time,
max_participants=payload.max_participants,
spot_id=payload.spot_id,
status="upcoming",
audit_status="pending",
)
db.add(ev)
await db.commit()
await db.refresh(ev)
return _to_brief(ev)
@router.get("/", response_model=PageResponse[EventBrief])
async def list_events(
city: str | None = None,
status_filter: str | None = Query(None, alias="status"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
base = select(Event).where(Event.audit_status == "approved")
count_base = select(func.count(Event.id)).where(Event.audit_status == "approved")
if city:
base = base.where(Event.city.ilike(f"%{city}%"))
count_base = count_base.where(Event.city.ilike(f"%{city}%"))
if status_filter:
base = base.where(Event.status == status_filter)
count_base = count_base.where(Event.status == status_filter)
total = (await db.execute(count_base)).scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(base.order_by(Event.start_time.asc().nulls_last()).offset(offset).limit(page_size))
items = result.scalars().all()
return PageResponse(total=total, items=[_to_brief(ev) for ev in items])
@router.get("/mine", response_model=PageResponse[EventBrief])
async def list_my_events(
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),
):
base = select(Event).where(Event.creator_id == current_user.id)
count_base = select(func.count(Event.id)).where(Event.creator_id == current_user.id)
total = (await db.execute(count_base)).scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(base.order_by(Event.created_at.desc()).offset(offset).limit(page_size))
items = result.scalars().all()
return PageResponse(total=total, items=[_to_brief(ev) for ev in items])
@router.get("/my-registrations", response_model=PageResponse[RegistrationOut])
async def list_my_registrations(
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),
):
base = select(EventRegistration).where(EventRegistration.user_id == current_user.id)
count_base = select(func.count(EventRegistration.id)).where(EventRegistration.user_id == current_user.id)
total = (await db.execute(count_base)).scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(base.order_by(EventRegistration.created_at.desc()).offset(offset).limit(page_size))
items = result.scalars().all()
return PageResponse(total=total, items=items)
@router.get("/{event_id}", response_model=EventDetail)
async def get_event(
event_id: int,
current_user: User | None = Depends(get_optional_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Event).where(Event.id == event_id))
ev = result.scalar_one_or_none()
if not ev:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
has_registered = False
if current_user:
reg = await db.execute(
select(EventRegistration).where(
EventRegistration.event_id == event_id,
EventRegistration.user_id == current_user.id,
)
)
has_registered = reg.scalar_one_or_none() is not None
photos_result = await db.execute(
select(EventPhoto).where(EventPhoto.event_id == event_id).order_by(EventPhoto.created_at.desc()).limit(50)
)
photos = photos_result.scalars().all()
return EventDetail(
id=ev.id,
title=ev.title,
city=ev.city,
cover_url=ev.cover_url,
location_name=ev.location_name,
start_time=ev.start_time,
end_time=ev.end_time,
status=ev.status,
audit_status=ev.audit_status,
creator=ev.creator,
registration_count=ev.registration_count,
max_participants=ev.max_participants,
created_at=ev.created_at,
description=ev.description,
spot_id=ev.spot_id,
reject_reason=ev.reject_reason,
has_registered=has_registered,
photos=photos,
)
@router.put("/{event_id}", response_model=EventBrief)
async def update_event(
event_id: int,
payload: EventUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Event).where(Event.id == event_id))
ev = result.scalar_one_or_none()
if not ev:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
if ev.creator_id != current_user.id and current_user.role not in ("admin", "moderator"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(ev, field, value)
await db.commit()
await db.refresh(ev)
return _to_brief(ev)
@router.post("/{event_id}/cancel")
async def cancel_event(
event_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Event).where(Event.id == event_id))
ev = result.scalar_one_or_none()
if not ev:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
if ev.creator_id != current_user.id and current_user.role not in ("admin", "moderator"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
ev.status = "cancelled"
regs = await db.execute(
select(EventRegistration).where(EventRegistration.event_id == event_id)
)
for reg in regs.scalars().all():
await send_notification(
db, reg.user_id, "event",
f"活动「{ev.title}」已取消",
content="很遗憾,该活动已被取消。",
ref_type="event", ref_id=ev.id,
)
await db.commit()
return {"code": 0, "message": "cancelled"}
# --- Registration ---
@router.post("/{event_id}/register", response_model=RegistrationOut, status_code=status.HTTP_201_CREATED)
async def register_event(
event_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Event).where(Event.id == event_id))
ev = result.scalar_one_or_none()
if not ev:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
if ev.audit_status != "approved":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="活动尚未通过审核")
if ev.status not in ("upcoming",):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="活动不在报名阶段")
if ev.max_participants > 0 and ev.registration_count >= ev.max_participants:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="报名人数已满")
existing = await db.execute(
select(EventRegistration).where(
EventRegistration.event_id == event_id,
EventRegistration.user_id == current_user.id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="已经报名过了")
reg = EventRegistration(event_id=event_id, user_id=current_user.id, status="registered")
db.add(reg)
ev.registration_count += 1
await send_notification(
db, ev.creator_id, "event",
f"{current_user.nickname} 报名了您的活动「{ev.title}",
ref_type="event", ref_id=ev.id,
)
await db.commit()
await db.refresh(reg)
return reg
@router.delete("/{event_id}/register")
async def cancel_registration(
event_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(EventRegistration).where(
EventRegistration.event_id == event_id,
EventRegistration.user_id == current_user.id,
)
)
reg = result.scalar_one_or_none()
if not reg:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未找到报名记录")
ev_result = await db.execute(select(Event).where(Event.id == event_id))
ev = ev_result.scalar_one_or_none()
if ev and ev.registration_count > 0:
ev.registration_count -= 1
await db.delete(reg)
await db.commit()
return {"code": 0, "message": "取消报名成功"}
@router.get("/{event_id}/registrations", response_model=list[RegistrationOut])
async def list_registrations(
event_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
ev_result = await db.execute(select(Event).where(Event.id == event_id))
ev = ev_result.scalar_one_or_none()
if not ev:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
if ev.creator_id != current_user.id and current_user.role not in ("admin", "moderator"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
result = await db.execute(
select(EventRegistration)
.where(EventRegistration.event_id == event_id)
.order_by(EventRegistration.created_at.desc())
)
return result.scalars().all()
# --- Event Photos ---
@router.post("/{event_id}/photos", response_model=EventPhotoOut, status_code=status.HTTP_201_CREATED)
async def add_event_photo(
event_id: int,
payload: EventPhotoCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
ev_result = await db.execute(select(Event).where(Event.id == event_id))
ev = ev_result.scalar_one_or_none()
if not ev:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
reg = await db.execute(
select(EventRegistration).where(
EventRegistration.event_id == event_id,
EventRegistration.user_id == current_user.id,
)
)
is_participant = reg.scalar_one_or_none() is not None
is_creator = ev.creator_id == current_user.id
is_admin = current_user.role in ("admin", "moderator")
if not (is_participant or is_creator or is_admin):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有活动参与者才能上传照片")
photo = EventPhoto(
event_id=event_id,
uploader_id=current_user.id,
image_url=payload.image_url,
caption=payload.caption,
spot_id=payload.spot_id,
)
db.add(photo)
await db.commit()
await db.refresh(photo)
return photo
@router.get("/{event_id}/photos", response_model=list[EventPhotoOut])
async def list_event_photos(
event_id: int,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(EventPhoto)
.where(EventPhoto.event_id == event_id)
.order_by(EventPhoto.created_at.desc())
)
return result.scalars().all()
@router.delete("/{event_id}/photos/{photo_id}")
async def delete_event_photo(
event_id: int,
photo_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(EventPhoto).where(EventPhoto.id == photo_id, EventPhoto.event_id == event_id)
)
photo = result.scalar_one_or_none()
if not photo:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
if photo.uploader_id != current_user.id and current_user.role not in ("admin", "moderator"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
await db.delete(photo)
await db.commit()
return {"code": 0, "message": "deleted"}
+112
View File
@@ -0,0 +1,112 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_active_user, get_db
from app.models.favorite import Favorite
from app.models.spot import Spot
from app.models.user import User
from app.schemas.common import PageResponse
from app.schemas.spot import SpotBrief
router = APIRouter()
def _spot_to_brief(spot: Spot) -> 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=spot.longitude,
latitude=spot.latitude,
cover_image_url=cover.image_url if cover else None,
audit_status=spot.audit_status,
created_at=spot.created_at,
)
@router.post("/{spot_id}", status_code=status.HTTP_201_CREATED)
async def add_favorite(
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))
if not result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Spot not found")
existing = await db.execute(
select(Favorite).where(
Favorite.user_id == current_user.id, Favorite.spot_id == spot_id
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Already favorited"
)
fav = Favorite(user_id=current_user.id, spot_id=spot_id)
db.add(fav)
await db.execute(
select(Spot).where(Spot.id == spot_id)
)
spot_obj = (await db.execute(select(Spot).where(Spot.id == spot_id))).scalar_one()
spot_obj.favorite_count = (spot_obj.favorite_count or 0) + 1
await db.commit()
return {"code": 0, "message": "success"}
@router.delete("/{spot_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_favorite(
spot_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Favorite).where(
Favorite.user_id == current_user.id, Favorite.spot_id == spot_id
)
)
fav = result.scalar_one_or_none()
if not fav:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Favorite not found"
)
spot_obj = (await db.execute(select(Spot).where(Spot.id == spot_id))).scalar_one_or_none()
if spot_obj:
spot_obj.favorite_count = max((spot_obj.favorite_count or 0) - 1, 0)
await db.delete(fav)
await db.commit()
@router.get("/", response_model=PageResponse[SpotBrief])
async def list_favorites(
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),
):
count_query = select(func.count(Favorite.id)).where(
Favorite.user_id == current_user.id
)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
offset = (page - 1) * page_size
query = (
select(Favorite)
.where(Favorite.user_id == current_user.id)
.order_by(Favorite.created_at.desc())
.offset(offset)
.limit(page_size)
)
result = await db.execute(query)
favorites = result.scalars().all()
return PageResponse(
total=total,
items=[_spot_to_brief(f.spot) for f in favorites],
)
+80
View File
@@ -0,0 +1,80 @@
import hashlib
import httpx
from fastapi import APIRouter, Query, Request
from app.core.config import settings
router = APIRouter()
CACHE_TTL = 3600
def _cache_key(prefix: str, **kwargs: str) -> str:
raw = "&".join(f"{k}={v}" for k, v in sorted(kwargs.items()) if v)
return f"map:{prefix}:{hashlib.md5(raw.encode()).hexdigest()}"
@router.get("/geocoder/reverse")
async def reverse_geocode(
request: Request,
location: str = Query(..., description="lat,lng"),
):
redis = request.app.state.redis
ck = _cache_key("rev", location=location)
if redis:
cached = await redis.get(ck)
if cached:
import json
return json.loads(cached)
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.get(
"https://apis.map.qq.com/ws/geocoder/v1/",
params={
"location": location,
"key": settings.TENCENT_MAP_KEY,
"get_poi": 1,
},
)
data = resp.json()
if redis and data.get("status") == 0:
import json
await redis.set(ck, json.dumps(data), ex=CACHE_TTL)
return data
@router.get("/place/search")
async def place_search(
request: Request,
keyword: str = Query(...),
boundary: str = Query(...),
):
redis = request.app.state.redis
ck = _cache_key("place", keyword=keyword, boundary=boundary)
if redis:
cached = await redis.get(ck)
if cached:
import json
return json.loads(cached)
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.get(
"https://apis.map.qq.com/ws/place/v1/search",
params={
"keyword": keyword,
"boundary": boundary,
"page_size": 10,
"page_index": 1,
"key": settings.TENCENT_MAP_KEY,
},
)
data = resp.json()
if redis and data.get("status") == 0:
import json
await redis.set(ck, json.dumps(data), ex=CACHE_TTL)
return data
+81
View File
@@ -0,0 +1,81 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_active_user, get_db
from app.models.membership import MembershipPlan, UserMembership
from app.models.user import User
from app.schemas.membership import MembershipPlanOut, PurchaseMembership, UserMembershipOut
router = APIRouter()
@router.get("/plans", response_model=list[MembershipPlanOut])
async def list_plans(db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(MembershipPlan)
.where(MembershipPlan.is_active == True)
.order_by(MembershipPlan.sort_order.asc())
)
return result.scalars().all()
@router.get("/me", response_model=UserMembershipOut | None)
async def get_my_membership(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
now = datetime.now(timezone.utc)
result = await db.execute(
select(UserMembership).where(
UserMembership.user_id == current_user.id,
UserMembership.is_active == True,
UserMembership.end_date >= now,
).order_by(UserMembership.end_date.desc()).limit(1)
)
return result.scalar_one_or_none()
@router.post("/purchase", response_model=UserMembershipOut)
async def purchase_membership(
payload: PurchaseMembership,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
plan_result = await db.execute(
select(MembershipPlan).where(
MembershipPlan.id == payload.plan_id,
MembershipPlan.is_active == True,
)
)
plan = plan_result.scalar_one_or_none()
if not plan:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会员方案不存在或已下架")
now = datetime.now(timezone.utc)
existing = await db.execute(
select(UserMembership).where(
UserMembership.user_id == current_user.id,
UserMembership.is_active == True,
UserMembership.end_date >= now,
).order_by(UserMembership.end_date.desc()).limit(1)
)
current_membership = existing.scalar_one_or_none()
start = current_membership.end_date if current_membership else now
end = start + timedelta(days=plan.duration_days)
membership = UserMembership(
user_id=current_user.id,
plan_id=plan.id,
start_date=start,
end_date=end,
is_active=True,
)
db.add(membership)
await db.commit()
await db.refresh(membership)
return membership
@@ -0,0 +1,97 @@
from fastapi import APIRouter, Depends, Query, status
from pydantic import BaseModel
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_active_user, get_db
from app.models.notification import Notification
from app.models.user import User
from app.schemas.common import PageResponse
router = APIRouter()
class NotificationOut(BaseModel):
id: int
type: str
title: str
content: str | None = None
ref_type: str | None = None
ref_id: int | None = None
is_read: bool = False
created_at: str | None = None
model_config = {"from_attributes": True}
class UnreadCount(BaseModel):
count: int
@router.get("/", response_model=PageResponse[NotificationOut])
async def list_notifications(
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),
):
base = select(func.count(Notification.id)).where(Notification.user_id == current_user.id)
total = (await db.execute(base)).scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(
select(Notification)
.where(Notification.user_id == current_user.id)
.order_by(Notification.created_at.desc())
.offset(offset)
.limit(page_size)
)
items = result.scalars().all()
return PageResponse(total=total, items=items)
@router.get("/unread-count", response_model=UnreadCount)
async def get_unread_count(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
count = (await db.execute(
select(func.count(Notification.id)).where(
Notification.user_id == current_user.id,
Notification.is_read.is_(False),
)
)).scalar() or 0
return UnreadCount(count=count)
@router.post("/read-all", status_code=status.HTTP_200_OK)
async def mark_all_read(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
await db.execute(
update(Notification)
.where(Notification.user_id == current_user.id, Notification.is_read.is_(False))
.values(is_read=True)
)
await db.commit()
return {"code": 0, "message": "success"}
@router.post("/{notification_id}/read")
async def mark_read(
notification_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Notification).where(
Notification.id == notification_id,
Notification.user_id == current_user.id,
)
)
n = result.scalar_one_or_none()
if n:
n.is_read = True
await db.commit()
return {"code": 0, "message": "success"}
+49
View File
@@ -0,0 +1,49 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_active_user, get_db
from app.models.point_ledger import PointLedger
from app.models.user import User
from app.schemas.common import PageResponse
from app.schemas.point import PointBalance, PointRecord
router = APIRouter()
@router.get("/me", response_model=PointBalance)
async def get_my_points(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(PointLedger)
.where(PointLedger.user_id == current_user.id)
.order_by(PointLedger.id.desc())
.limit(1)
)
last = result.scalar_one_or_none()
return PointBalance(balance=last.balance if last else 0)
@router.get("/me/records", response_model=PageResponse[PointRecord])
async def get_my_point_records(
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),
):
base = select(PointLedger).where(PointLedger.user_id == current_user.id)
total_result = await db.execute(
select(func.count(PointLedger.id)).where(PointLedger.user_id == current_user.id)
)
total = total_result.scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(
base.order_by(PointLedger.id.desc()).offset(offset).limit(page_size)
)
records = result.scalars().all()
return PageResponse(total=total, items=records)
+51
View File
@@ -0,0 +1,51 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db
from app.models.promotion import Promotion
from app.schemas.promotion import PromotionClick, PromotionOut
router = APIRouter()
@router.get("/", response_model=list[PromotionOut])
async def list_promotions(
position: str = Query(default="home_banner"),
db: AsyncSession = Depends(get_db),
):
now = datetime.now(timezone.utc)
result = await db.execute(
select(Promotion).where(
and_(
Promotion.is_active == True,
Promotion.position == position,
(Promotion.start_time == None) | (Promotion.start_time <= now),
(Promotion.end_time == None) | (Promotion.end_time >= now),
)
).order_by(Promotion.sort_order.asc())
)
items = result.scalars().all()
for item in items:
item.impressions += 1
await db.commit()
return items
@router.post("/click")
async def record_click(
payload: PromotionClick,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Promotion).where(Promotion.id == payload.promotion_id)
)
promo = result.scalar_one_or_none()
if promo:
promo.clicks += 1
await db.commit()
return {"code": 0, "message": "ok"}
+80
View File
@@ -0,0 +1,80 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_active_user, get_db
from app.models.rating import Rating
from app.models.spot import Spot
from app.models.user import User
from app.schemas.common import PageResponse
from app.schemas.rating import RatingCreate, RatingOut
router = APIRouter()
@router.post("/spots/{spot_id}/rate", response_model=RatingOut)
async def rate_spot(
spot_id: int,
payload: RatingCreate,
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")
existing = await db.execute(
select(Rating).where(Rating.user_id == current_user.id, Rating.spot_id == spot_id)
)
rating = existing.scalar_one_or_none()
if rating:
rating.score = payload.score
rating.short_comment = payload.short_comment
else:
rating = Rating(
spot_id=spot_id,
user_id=current_user.id,
score=payload.score,
short_comment=payload.short_comment,
)
db.add(rating)
await db.flush()
agg = await db.execute(
select(func.avg(Rating.score), func.count(Rating.id)).where(Rating.spot_id == spot_id)
)
avg_score, count = agg.one()
spot.avg_rating = round(float(avg_score), 2) if avg_score else None
spot.rating_count = count or 0
await db.commit()
await db.refresh(rating)
return rating
@router.get("/spots/{spot_id}/ratings", response_model=PageResponse[RatingOut])
async def list_ratings(
spot_id: int,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
count_result = await db.execute(
select(func.count(Rating.id)).where(Rating.spot_id == spot_id)
)
total = count_result.scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(
select(Rating)
.where(Rating.spot_id == spot_id)
.order_by(Rating.created_at.desc())
.offset(offset)
.limit(page_size)
)
ratings = result.scalars().all()
return PageResponse(total=total, items=[RatingOut.model_validate(r) for r in ratings])
+74
View File
@@ -0,0 +1,74 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db
from app.models.spot import Spot
from app.models.tag import SpotTag, Tag
from app.schemas.common import PageResponse
from app.schemas.spot import SpotBrief
router = APIRouter()
def _spot_to_brief(spot: Spot) -> 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=spot.longitude,
latitude=spot.latitude,
cover_image_url=cover.image_url if cover else None,
audit_status=spot.audit_status,
avg_rating=spot.avg_rating,
created_at=spot.created_at,
)
@router.get("", response_model=PageResponse[SpotBrief])
async def search_spots(
q: str = Query(..., min_length=1),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
if not q.strip():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Query cannot be empty")
pattern = f"%{q}%"
tag_spot_ids = (
select(SpotTag.spot_id)
.join(Tag, SpotTag.tag_id == Tag.id)
.where(Tag.name.ilike(pattern))
.distinct()
.correlate(None)
.scalar_subquery()
)
filters = (
Spot.audit_status == "approved",
or_(
Spot.title.ilike(pattern),
Spot.description.ilike(pattern),
Spot.id.in_(tag_spot_ids),
),
)
count_result = await db.execute(select(func.count(Spot.id)).where(*filters))
total = count_result.scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(
select(Spot)
.where(*filters)
.order_by(Spot.created_at.desc())
.offset(offset)
.limit(page_size)
)
spots = result.scalars().all()
return PageResponse(total=total, items=[_spot_to_brief(s) for s in spots])
+402
View File
@@ -0,0 +1,402 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
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.shooting import ShootingApplication, ShootingRequest
from app.models.user import User
from app.schemas.common import PageResponse
from app.schemas.shooting import (
ApplicationCreate,
ApplicationOut,
ShootingRequestBrief,
ShootingRequestCreate,
ShootingRequestDetail,
ShootingRequestUpdate,
)
from app.services.notification_service import send_notification
router = APIRouter()
ROLE_LABELS = {
"photographer": "摄影师",
"cosplayer": "Coser",
"both": "不限",
}
def _to_brief(sr: ShootingRequest) -> ShootingRequestBrief:
return ShootingRequestBrief(
id=sr.id,
title=sr.title,
city=sr.city,
style=sr.style,
shoot_date=sr.shoot_date,
is_free=sr.is_free,
budget_min=float(sr.budget_min) if sr.budget_min is not None else None,
budget_max=float(sr.budget_max) if sr.budget_max is not None else None,
role_needed=sr.role_needed,
status=sr.status,
audit_status=sr.audit_status,
creator=sr.creator,
application_count=len(sr.applications) if sr.applications else 0,
created_at=sr.created_at,
)
# --- ShootingRequest CRUD ---
@router.post("/", response_model=ShootingRequestBrief, status_code=status.HTTP_201_CREATED)
async def create_shooting_request(
payload: ShootingRequestCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
sr = ShootingRequest(
creator_id=current_user.id,
title=payload.title,
city=payload.city,
description=payload.description,
style=payload.style,
shoot_date=payload.shoot_date,
is_free=payload.is_free,
budget_min=payload.budget_min if not payload.is_free else None,
budget_max=payload.budget_max if not payload.is_free else None,
role_needed=payload.role_needed,
max_applicants=payload.max_applicants,
contact_info=payload.contact_info,
spot_id=payload.spot_id,
status="open",
audit_status="pending",
)
db.add(sr)
await db.commit()
await db.refresh(sr)
return _to_brief(sr)
@router.get("/", response_model=PageResponse[ShootingRequestBrief])
async def list_shooting_requests(
city: str | None = None,
style: str | None = None,
role_needed: str | None = None,
is_free: bool | None = None,
status_filter: str | None = Query(None, alias="status"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
base = select(ShootingRequest).where(ShootingRequest.audit_status == "approved")
count_base = select(func.count(ShootingRequest.id)).where(ShootingRequest.audit_status == "approved")
if city:
base = base.where(ShootingRequest.city.ilike(f"%{city}%"))
count_base = count_base.where(ShootingRequest.city.ilike(f"%{city}%"))
if style:
base = base.where(ShootingRequest.style.ilike(f"%{style}%"))
count_base = count_base.where(ShootingRequest.style.ilike(f"%{style}%"))
if role_needed:
base = base.where(ShootingRequest.role_needed == role_needed)
count_base = count_base.where(ShootingRequest.role_needed == role_needed)
if is_free is not None:
base = base.where(ShootingRequest.is_free == is_free)
count_base = count_base.where(ShootingRequest.is_free == is_free)
if status_filter:
base = base.where(ShootingRequest.status == status_filter)
count_base = count_base.where(ShootingRequest.status == status_filter)
total = (await db.execute(count_base)).scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(base.order_by(ShootingRequest.created_at.desc()).offset(offset).limit(page_size))
items = result.scalars().all()
return PageResponse(total=total, items=[_to_brief(sr) for sr in items])
@router.get("/mine", response_model=PageResponse[ShootingRequestBrief])
async def list_my_shooting_requests(
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),
):
base = select(ShootingRequest).where(ShootingRequest.creator_id == current_user.id)
count_base = select(func.count(ShootingRequest.id)).where(ShootingRequest.creator_id == current_user.id)
total = (await db.execute(count_base)).scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(base.order_by(ShootingRequest.created_at.desc()).offset(offset).limit(page_size))
items = result.scalars().all()
return PageResponse(total=total, items=[_to_brief(sr) for sr in items])
@router.get("/my-applications", response_model=PageResponse[ApplicationOut])
async def list_my_applications(
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),
):
base = select(ShootingApplication).where(ShootingApplication.applicant_id == current_user.id)
count_base = select(func.count(ShootingApplication.id)).where(ShootingApplication.applicant_id == current_user.id)
total = (await db.execute(count_base)).scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(base.order_by(ShootingApplication.created_at.desc()).offset(offset).limit(page_size))
items = result.scalars().all()
return PageResponse(total=total, items=items)
@router.get("/{request_id}", response_model=ShootingRequestDetail)
async def get_shooting_request(
request_id: int,
current_user: User | None = Depends(get_optional_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))
sr = result.scalar_one_or_none()
if not sr:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
has_applied = False
if current_user:
app_result = await db.execute(
select(ShootingApplication).where(
ShootingApplication.request_id == request_id,
ShootingApplication.applicant_id == current_user.id,
)
)
has_applied = app_result.scalar_one_or_none() is not None
return ShootingRequestDetail(
id=sr.id,
title=sr.title,
city=sr.city,
style=sr.style,
shoot_date=sr.shoot_date,
is_free=sr.is_free,
budget_min=float(sr.budget_min) if sr.budget_min is not None else None,
budget_max=float(sr.budget_max) if sr.budget_max is not None else None,
role_needed=sr.role_needed,
status=sr.status,
audit_status=sr.audit_status,
creator=sr.creator,
application_count=len(sr.applications) if sr.applications else 0,
created_at=sr.created_at,
description=sr.description,
max_applicants=sr.max_applicants,
contact_info=sr.contact_info if (current_user and (sr.creator_id == current_user.id or current_user.role in ("admin", "moderator"))) else None,
spot_id=sr.spot_id,
reject_reason=sr.reject_reason,
has_applied=has_applied,
)
@router.put("/{request_id}", response_model=ShootingRequestBrief)
async def update_shooting_request(
request_id: int,
payload: ShootingRequestUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))
sr = result.scalar_one_or_none()
if not sr:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
if sr.creator_id != current_user.id and current_user.role not in ("admin", "moderator"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(sr, field, value)
await db.commit()
await db.refresh(sr)
return _to_brief(sr)
@router.post("/{request_id}/close")
async def close_shooting_request(
request_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))
sr = result.scalar_one_or_none()
if not sr:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
if sr.creator_id != current_user.id and current_user.role not in ("admin", "moderator"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
sr.status = "closed"
await db.commit()
return {"code": 0, "message": "closed"}
# --- Applications ---
@router.post("/{request_id}/apply", response_model=ApplicationOut, status_code=status.HTTP_201_CREATED)
async def apply_to_shooting(
request_id: int,
payload: ApplicationCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))
sr = result.scalar_one_or_none()
if not sr:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
if sr.status != "open":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该约拍已关闭")
if sr.audit_status != "approved":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该约拍尚未通过审核")
if sr.creator_id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能报名自己的约拍")
existing = await db.execute(
select(ShootingApplication).where(
ShootingApplication.request_id == request_id,
ShootingApplication.applicant_id == current_user.id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="已经报名过了")
app = ShootingApplication(
request_id=request_id,
applicant_id=current_user.id,
message=payload.message,
status="pending",
)
db.add(app)
await send_notification(
db, sr.creator_id, "shooting",
f"有人报名了您的约拍「{sr.title}",
content=f"{current_user.nickname} 报名了您的约拍",
ref_type="shooting", ref_id=sr.id,
)
await db.commit()
await db.refresh(app)
return app
@router.get("/{request_id}/applications", response_model=list[ApplicationOut])
async def list_applications(
request_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))
sr = result.scalar_one_or_none()
if not sr:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
if sr.creator_id != current_user.id and current_user.role not in ("admin", "moderator"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only creator can view applications")
result = await db.execute(
select(ShootingApplication)
.where(ShootingApplication.request_id == request_id)
.order_by(ShootingApplication.created_at.desc())
)
return result.scalars().all()
@router.post("/{request_id}/applications/{app_id}/accept")
async def accept_application(
request_id: int,
app_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
sr_result = await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))
sr = sr_result.scalar_one_or_none()
if not sr or sr.creator_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
app_result = await db.execute(
select(ShootingApplication).where(
ShootingApplication.id == app_id,
ShootingApplication.request_id == request_id,
)
)
app = app_result.scalar_one_or_none()
if not app:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Application not found")
if app.status != "pending":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该报名已被处理")
app.status = "accepted"
accepted_count = sum(1 for a in sr.applications if a.status == "accepted") + 1
if accepted_count >= sr.max_applicants:
sr.status = "matched"
await send_notification(
db, app.applicant_id, "shooting",
f"您的约拍报名「{sr.title}」已被接受",
content="对方已接受您的报名,请查看详情。",
ref_type="shooting", ref_id=sr.id,
)
await db.commit()
return {"code": 0, "message": "accepted"}
@router.post("/{request_id}/applications/{app_id}/reject")
async def reject_application(
request_id: int,
app_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
sr_result = await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))
sr = sr_result.scalar_one_or_none()
if not sr or sr.creator_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
app_result = await db.execute(
select(ShootingApplication).where(
ShootingApplication.id == app_id,
ShootingApplication.request_id == request_id,
)
)
app = app_result.scalar_one_or_none()
if not app:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Application not found")
app.status = "rejected"
await send_notification(
db, app.applicant_id, "shooting",
f"您的约拍报名「{sr.title}」未通过",
ref_type="shooting", ref_id=sr.id,
)
await db.commit()
return {"code": 0, "message": "rejected"}
@router.delete("/{request_id}/applications/withdraw")
async def withdraw_application(
request_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ShootingApplication).where(
ShootingApplication.request_id == request_id,
ShootingApplication.applicant_id == current_user.id,
)
)
app = result.scalar_one_or_none()
if not app:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Application not found")
if app.status == "accepted":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="已被接受的报名无法撤回")
await db.delete(app)
await db.commit()
return {"code": 0, "message": "withdrawn"}
+454
View File
@@ -0,0 +1,454 @@
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
+63
View File
@@ -0,0 +1,63 @@
from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_active_user, get_db
from app.models.comment import Comment
from app.models.event import Event, EventRegistration
from app.models.favorite import Favorite
from app.models.promotion import Promotion
from app.models.rating import Rating
from app.models.shooting import ShootingRequest, ShootingApplication
from app.models.spot import Spot
from app.models.user import User
router = APIRouter()
@router.get("/overview")
async def get_stats_overview(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
if current_user.role not in ("admin", "moderator"):
return {"detail": "Not allowed"}
user_count = (await db.execute(select(func.count(User.id)))).scalar() or 0
spot_count = (await db.execute(select(func.count(Spot.id)))).scalar() or 0
approved_spot_count = (await db.execute(
select(func.count(Spot.id)).where(Spot.audit_status == "approved")
)).scalar() or 0
pending_spot_count = (await db.execute(
select(func.count(Spot.id)).where(Spot.audit_status == "pending")
)).scalar() or 0
comment_count = (await db.execute(select(func.count(Comment.id)))).scalar() or 0
rating_count = (await db.execute(select(func.count(Rating.id)))).scalar() or 0
favorite_count = (await db.execute(select(func.count(Favorite.id)))).scalar() or 0
shooting_count = (await db.execute(select(func.count(ShootingRequest.id)))).scalar() or 0
event_count = (await db.execute(select(func.count(Event.id)))).scalar() or 0
promo_result = await db.execute(
select(
func.coalesce(func.sum(Promotion.impressions), 0),
func.coalesce(func.sum(Promotion.clicks), 0),
)
)
promo_row = promo_result.one()
total_impressions = int(promo_row[0])
total_clicks = int(promo_row[1])
return {
"user_count": user_count,
"spot_count": spot_count,
"approved_spot_count": approved_spot_count,
"pending_spot_count": pending_spot_count,
"comment_count": comment_count,
"rating_count": rating_count,
"favorite_count": favorite_count,
"shooting_count": shooting_count,
"event_count": event_count,
"promo_impressions": total_impressions,
"promo_clicks": total_clicks,
"promo_ctr": round(total_clicks / total_impressions * 100, 2) if total_impressions > 0 else 0,
}
+93
View File
@@ -0,0 +1,93 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_active_user, get_db
from app.models.spot import Spot
from app.models.tag import SpotTag, Tag
from app.models.user import User
from app.schemas.tag import TagOut
router = APIRouter()
class TagAttach(BaseModel):
tag_id: int
@router.get("/tags", response_model=list[TagOut])
async def list_tags(
sort: str = Query(default="hot", regex="^(hot|name)$"),
db: AsyncSession = Depends(get_db),
):
query = select(Tag).where(Tag.is_active.is_(True))
if sort == "hot":
query = query.order_by(Tag.usage_count.desc())
else:
query = query.order_by(Tag.name.asc())
result = await db.execute(query)
return result.scalars().all()
@router.post("/spots/{spot_id}/tags", status_code=status.HTTP_201_CREATED)
async def add_tag_to_spot(
spot_id: int,
payload: TagAttach,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Spot).where(Spot.id == spot_id))
if not result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Spot not found")
tag_result = await db.execute(select(Tag).where(Tag.id == payload.tag_id))
tag = tag_result.scalar_one_or_none()
if not tag:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tag not found")
existing = await db.execute(
select(SpotTag).where(SpotTag.spot_id == spot_id, SpotTag.tag_id == payload.tag_id)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Tag already added")
spot_tag = SpotTag(spot_id=spot_id, tag_id=payload.tag_id)
db.add(spot_tag)
tag.usage_count = (tag.usage_count or 0) + 1
await db.commit()
return {"code": 0, "message": "Tag added"}
@router.delete("/spots/{spot_id}/tags/{tag_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_tag_from_spot(
spot_id: int,
tag_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
spot_result = await db.execute(select(Spot).where(Spot.id == spot_id))
spot = 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")
link_result = await db.execute(
select(SpotTag).where(SpotTag.spot_id == spot_id, SpotTag.tag_id == tag_id)
)
link = link_result.scalar_one_or_none()
if not link:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tag not linked to spot")
await db.delete(link)
tag_result = await db.execute(select(Tag).where(Tag.id == tag_id))
tag = tag_result.scalar_one_or_none()
if tag and tag.usage_count > 0:
tag.usage_count -= 1
await db.commit()
+71
View File
@@ -0,0 +1,71 @@
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status
from app.core.deps import get_current_active_user
from app.core.rate_limit import RateLimiter
from app.core.storage import S3StorageBackend
from app.models.user import User
from app.schemas.upload import PresignedUrlRequest, PresignedUrlResponse, UploadResponse
router = APIRouter()
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
MAX_SIZE = 10 * 1024 * 1024 # 10MB
@router.post(
"/image",
response_model=UploadResponse,
dependencies=[Depends(RateLimiter(times=20, seconds=60))],
)
async def upload_image(
request: Request,
file: UploadFile,
current_user: User = Depends(get_current_active_user),
):
if file.content_type not in ALLOWED_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported file type: {file.content_type}",
)
data = await file.read()
if len(data) > MAX_SIZE:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="File too large, max 10MB",
)
storage = request.app.state.storage
url = storage.upload(data, file.filename or "image.jpg", file.content_type or "")
return UploadResponse(url=url, filename=file.filename or "image.jpg")
@router.post("/presigned", response_model=PresignedUrlResponse)
async def get_presigned_upload_url(
request: Request,
payload: PresignedUrlRequest,
current_user: User = Depends(get_current_active_user),
):
storage = request.app.state.storage
if not isinstance(storage, S3StorageBackend):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Presigned URLs only available with S3 storage backend",
)
ext = Path(payload.filename).suffix or ".jpg"
file_key = f"images/{uuid.uuid4().hex}{ext}"
upload_url = storage.generate_presigned_url(
file_key, content_type=payload.content_type
)
public_url = storage._get_url(file_key)
return PresignedUrlResponse(
upload_url=upload_url,
file_key=file_key,
public_url=public_url,
)
+110
View File
@@ -0,0 +1,110 @@
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_active_user, get_db
from app.core.security import get_password_hash, verify_password
from app.models.favorite import Favorite
from app.models.rating import Rating
from app.models.spot import Spot
from app.models.user import User
from app.schemas.user import UserInfo, UserUpdate
router = APIRouter()
class UserStats(BaseModel):
spot_count: int = 0
approved_count: int = 0
favorite_count: int = 0
rating_received: int = 0
class ChangePassword(BaseModel):
old_password: str = Field(..., min_length=6)
new_password: str = Field(..., min_length=6)
@router.get("/me", response_model=UserInfo)
async def get_me(current_user: User = Depends(get_current_active_user)):
return current_user
@router.get("/me/stats", response_model=UserStats)
async def get_my_stats(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
spot_count = (await db.execute(
select(func.count(Spot.id)).where(Spot.creator_id == current_user.id)
)).scalar() or 0
approved_count = (await db.execute(
select(func.count(Spot.id)).where(
Spot.creator_id == current_user.id, Spot.audit_status == "approved"
)
)).scalar() or 0
favorite_count = (await db.execute(
select(func.count(Favorite.id)).where(Favorite.user_id == current_user.id)
)).scalar() or 0
rating_received = (await db.execute(
select(func.count(Rating.id)).where(
Rating.spot_id.in_(
select(Spot.id).where(Spot.creator_id == current_user.id)
)
)
)).scalar() or 0
return UserStats(
spot_count=spot_count,
approved_count=approved_count,
favorite_count=favorite_count,
rating_received=rating_received,
)
@router.put("/me", response_model=UserInfo)
async def update_me(
payload: UserUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(current_user, field, value)
db.add(current_user)
await db.commit()
await db.refresh(current_user)
return current_user
@router.post("/me/change-password")
async def change_password(
payload: ChangePassword,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
if not verify_password(payload.old_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="旧密码不正确",
)
current_user.password_hash = get_password_hash(payload.new_password)
db.add(current_user)
await db.commit()
return {"code": 0, "message": "密码修改成功"}
@router.get("/{user_id}", response_model=UserInfo)
async def get_user(user_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return user
+26
View File
@@ -0,0 +1,26 @@
from fastapi import APIRouter
from app.api.v1.endpoints import admin, app_nav_config, auth, comments, corrections, events, favorites, map_proxy, membership, notifications, points, promotions, ratings, search, shooting, spots, stats, tags, upload, users
v1_router = APIRouter()
v1_router.include_router(auth.router, prefix="/auth", tags=["认证"])
v1_router.include_router(users.router, prefix="/users", tags=["用户"])
v1_router.include_router(spots.router, prefix="/spots", tags=["地点"])
v1_router.include_router(favorites.router, prefix="/favorites", tags=["收藏"])
v1_router.include_router(upload.router, prefix="/upload", tags=["上传"])
v1_router.include_router(points.router, prefix="/points", tags=["积分"])
v1_router.include_router(comments.router, tags=["评论"])
v1_router.include_router(ratings.router, tags=["评分"])
v1_router.include_router(tags.router, tags=["标签"])
v1_router.include_router(search.router, prefix="/search", tags=["搜索"])
v1_router.include_router(corrections.router, tags=["校正建议"])
v1_router.include_router(map_proxy.router, prefix="/map", tags=["地图"])
v1_router.include_router(notifications.router, prefix="/notifications", tags=["通知"])
v1_router.include_router(shooting.router, prefix="/shooting", tags=["约拍"])
v1_router.include_router(events.router, prefix="/events", tags=["活动"])
v1_router.include_router(promotions.router, prefix="/promotions", tags=["推广"])
v1_router.include_router(membership.router, prefix="/membership", tags=["会员"])
v1_router.include_router(stats.router, prefix="/stats", tags=["统计"])
v1_router.include_router(app_nav_config.router, prefix="/ui-config", tags=["前端配置"])
v1_router.include_router(admin.router, prefix="/admin", tags=["管理端"])