Initial project commit
This commit is contained in:
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()
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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])
|
||||
@@ -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"}
|
||||
@@ -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],
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
@@ -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])
|
||||
@@ -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])
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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=["管理端"])
|
||||
Reference in New Issue
Block a user