Initial project commit
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
from sqladmin.authentication import AuthenticationBackend
|
||||
from sqlalchemy import select
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.core.security import verify_password
|
||||
from app.db.session import sync_engine
|
||||
from app.models.user import User
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
class AdminAuthBackend(AuthenticationBackend):
|
||||
async def login(self, request: Request) -> bool:
|
||||
form = await request.form()
|
||||
username = form.get("username", "")
|
||||
password = form.get("password", "")
|
||||
|
||||
with Session(sync_engine) as session:
|
||||
result = session.execute(
|
||||
select(User).where(
|
||||
(User.phone == username) | (User.email == username)
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
return False
|
||||
|
||||
if user.role not in ("admin", "moderator"):
|
||||
return False
|
||||
|
||||
if not verify_password(str(password), user.password_hash):
|
||||
return False
|
||||
|
||||
request.session.update({"admin_user_id": user.id})
|
||||
return True
|
||||
|
||||
async def logout(self, request: Request) -> bool:
|
||||
request.session.clear()
|
||||
return True
|
||||
|
||||
async def authenticate(self, request: Request) -> bool:
|
||||
user_id = request.session.get("admin_user_id")
|
||||
if not user_id:
|
||||
return False
|
||||
|
||||
with Session(sync_engine) as session:
|
||||
result = session.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or user.role not in ("admin", "moderator"):
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Admin media library API - list & upload images (session-authenticated)."""
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter(prefix="/admin/api/media", tags=["admin-media"])
|
||||
|
||||
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
MAX_SIZE = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def _check_admin_session(request: Request):
|
||||
uid = request.session.get("admin_user_id")
|
||||
if not uid:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def list_media(request: Request):
|
||||
_check_admin_session(request)
|
||||
|
||||
images = []
|
||||
|
||||
if settings.STORAGE_BACKEND == "local":
|
||||
upload_dir = Path(settings.LOCAL_STORAGE_PATH)
|
||||
if upload_dir.exists():
|
||||
for f in sorted(upload_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
if f.is_file() and f.suffix.lower() in ALLOWED_EXTENSIONS:
|
||||
images.append({
|
||||
"url": f"/uploads/{f.name}",
|
||||
"name": f.name,
|
||||
"size": f.stat().st_size,
|
||||
})
|
||||
else:
|
||||
storage = request.app.state.storage
|
||||
try:
|
||||
resp = storage.client.list_objects_v2(Bucket=storage.bucket, Prefix="images/", MaxKeys=200)
|
||||
for obj in resp.get("Contents", []):
|
||||
key = obj["Key"]
|
||||
ext = Path(key).suffix.lower()
|
||||
if ext in ALLOWED_EXTENSIONS:
|
||||
images.append({
|
||||
"url": storage._get_url(key),
|
||||
"name": Path(key).name,
|
||||
"size": obj.get("Size", 0),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return JSONResponse({"images": images})
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_media(request: Request, file: UploadFile):
|
||||
_check_admin_session(request)
|
||||
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="缺少文件")
|
||||
|
||||
ext = Path(file.filename).suffix.lower()
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的文件类型: {ext}")
|
||||
|
||||
data = await file.read()
|
||||
if len(data) > MAX_SIZE:
|
||||
raise HTTPException(status_code=413, detail="文件过大,最大10MB")
|
||||
|
||||
storage = request.app.state.storage
|
||||
url = storage.upload(data, file.filename, file.content_type or "")
|
||||
|
||||
return JSONResponse({"url": url, "name": file.filename})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
"""Custom WTForms fields/widgets for the admin image picker."""
|
||||
from markupsafe import Markup
|
||||
from wtforms import StringField
|
||||
from wtforms.widgets import TextInput
|
||||
|
||||
|
||||
class ImagePickerWidget(TextInput):
|
||||
def __call__(self, field, **kwargs):
|
||||
kwargs.setdefault("class", "form-control image-picker-input")
|
||||
kwargs["style"] = kwargs.get("style", "") + ";display:none"
|
||||
kwargs["id"] = field.id
|
||||
field_id = field.id
|
||||
val = field._value() if hasattr(field, "_value") else (field.data or "")
|
||||
|
||||
hidden = (
|
||||
f'<input type="text" name="{field.name}" id="{field_id}" '
|
||||
f'value="{val}" class="form-control image-picker-input" style="display:none">'
|
||||
)
|
||||
|
||||
preview_src = val or ""
|
||||
preview_display = "block" if preview_src else "none"
|
||||
|
||||
html = f"""
|
||||
<div class="image-picker-wrapper" data-field="{field_id}">
|
||||
{hidden}
|
||||
<div class="image-picker-preview" id="preview-{field_id}" style="display:{preview_display}">
|
||||
<img src="{preview_src}" id="preview-img-{field_id}" alt="预览">
|
||||
<button type="button" class="image-picker-remove" onclick="imagePickerClear('{field_id}')"
|
||||
title="移除图片">×</button>
|
||||
</div>
|
||||
<div class="image-picker-actions">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||
onclick="imagePickerOpen('{field_id}')">
|
||||
<i class="fa-solid fa-images"></i> 从素材库选择
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-success"
|
||||
onclick="imagePickerUpload('{field_id}')">
|
||||
<i class="fa-solid fa-upload"></i> 上传新图片
|
||||
</button>
|
||||
<input type="file" id="file-{field_id}" accept="image/*"
|
||||
style="display:none" onchange="imagePickerFileSelected('{field_id}', this)">
|
||||
</div>
|
||||
<div class="image-picker-url-display" id="url-display-{field_id}"
|
||||
style="margin-top:6px;font-size:12px;color:#94a3b8;word-break:break-all">
|
||||
{val}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
return Markup(html)
|
||||
|
||||
|
||||
class ImagePickerField(StringField):
|
||||
widget = ImagePickerWidget()
|
||||
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=["管理端"])
|
||||
@@ -0,0 +1,31 @@
|
||||
from celery import Celery
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
celery_app = Celery(
|
||||
"ciyuan_viewfinder",
|
||||
broker=settings.REDIS_URL,
|
||||
backend=settings.REDIS_URL,
|
||||
)
|
||||
|
||||
celery_app.conf.update(
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
timezone="Asia/Shanghai",
|
||||
enable_utc=True,
|
||||
task_track_started=True,
|
||||
task_acks_late=True,
|
||||
worker_prefetch_multiplier=1,
|
||||
)
|
||||
|
||||
from celery.schedules import crontab
|
||||
|
||||
celery_app.conf.beat_schedule = {
|
||||
"aggregate-daily-stats": {
|
||||
"task": "app.tasks.stats_tasks.aggregate_daily_stats",
|
||||
"schedule": 3600.0,
|
||||
},
|
||||
}
|
||||
|
||||
celery_app.autodiscover_tasks(["app.tasks"])
|
||||
@@ -0,0 +1,31 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str
|
||||
DATABASE_URL_SYNC: str
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
SECRET_KEY: str
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 43200
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 60
|
||||
|
||||
STORAGE_BACKEND: str = "local"
|
||||
LOCAL_STORAGE_PATH: str = "./uploads"
|
||||
S3_ENDPOINT: str = ""
|
||||
S3_ACCESS_KEY: str = ""
|
||||
S3_SECRET_KEY: str = ""
|
||||
S3_BUCKET: str = "ciyuan-viewfinder"
|
||||
S3_REGION: str = ""
|
||||
S3_PUBLIC_URL: str = ""
|
||||
|
||||
TENCENT_MAP_KEY: str = ""
|
||||
|
||||
SENTRY_DSN: str = ""
|
||||
LOG_JSON: bool = False
|
||||
LOG_LEVEL: str = "INFO"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,69 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import decode_token
|
||||
from app.db.session import async_session_factory
|
||||
from app.models.user import User
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session_factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
user_id: str | None = payload.get("sub")
|
||||
if user_id is None or payload.get("type") != "access":
|
||||
raise credentials_exception
|
||||
except ValueError:
|
||||
raise credentials_exception
|
||||
|
||||
result = await db.execute(select(User).where(User.id == int(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is disabled",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_optional_current_user(
|
||||
token: str | None = Depends(OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User | None:
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None or payload.get("type") != "access":
|
||||
return None
|
||||
except ValueError:
|
||||
return None
|
||||
result = await db.execute(select(User).where(User.id == int(user_id)))
|
||||
return result.scalar_one_or_none()
|
||||
@@ -0,0 +1,40 @@
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class JSONFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
log_entry = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
"module": record.module,
|
||||
"function": record.funcName,
|
||||
"line": record.lineno,
|
||||
}
|
||||
if record.exc_info and record.exc_info[1]:
|
||||
log_entry["exception"] = self.formatException(record.exc_info)
|
||||
return json.dumps(log_entry, ensure_ascii=False)
|
||||
|
||||
|
||||
def setup_logging(json_format: bool = False, level: int = logging.INFO):
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level)
|
||||
|
||||
for handler in root.handlers[:]:
|
||||
root.removeHandler(handler)
|
||||
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
if json_format:
|
||||
handler.setFormatter(JSONFormatter())
|
||||
else:
|
||||
handler.setFormatter(logging.Formatter(
|
||||
"%(asctime)s %(levelname)s [%(name)s] %(message)s"
|
||||
))
|
||||
root.addHandler(handler)
|
||||
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
||||
@@ -0,0 +1,49 @@
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Sliding window rate limiter backed by Redis.
|
||||
|
||||
Usage as FastAPI dependency:
|
||||
@router.post("/action", dependencies=[Depends(RateLimiter(times=5, seconds=60))])
|
||||
"""
|
||||
|
||||
def __init__(self, times: int = 10, seconds: int = 60):
|
||||
self.times = times
|
||||
self.seconds = seconds
|
||||
|
||||
async def __call__(self, request: Request) -> None:
|
||||
redis = getattr(request.app.state, "redis", None)
|
||||
if redis is None:
|
||||
return
|
||||
|
||||
identifier = self._get_identifier(request)
|
||||
key = f"rl:{request.url.path}:{identifier}"
|
||||
|
||||
try:
|
||||
pipe = redis.pipeline()
|
||||
pipe.incr(key)
|
||||
pipe.expire(key, self.seconds)
|
||||
results = await pipe.execute()
|
||||
current = results[0]
|
||||
except RedisError:
|
||||
logger.warning("Rate limiter skipped because Redis is unavailable", exc_info=True)
|
||||
return
|
||||
|
||||
if current > self.times:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many requests, please try again later",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_identifier(request: Request) -> str:
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
@@ -0,0 +1,46 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(
|
||||
subject: str | int, expires_delta: timedelta | None = None
|
||||
) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
expire = now + (
|
||||
expires_delta
|
||||
if expires_delta
|
||||
else timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
)
|
||||
to_encode = {"sub": str(subject), "exp": expire, "type": "access"}
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(subject: str | int) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
expire = now + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode = {"sub": str(subject), "exp": expire, "type": "refresh"}
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
except JWTError as exc:
|
||||
raise ValueError("Invalid token") from exc
|
||||
return payload
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config as BotoConfig
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class StorageBackend(ABC):
|
||||
@abstractmethod
|
||||
def upload(self, file_data: bytes, filename: str, content_type: str = "") -> str:
|
||||
"""Upload file and return its public URL."""
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, key: str) -> None:
|
||||
"""Delete a file by key/path."""
|
||||
|
||||
@abstractmethod
|
||||
def generate_presigned_url(self, key: str, content_type: str = "", expires: int = 3600) -> str:
|
||||
"""Generate a presigned upload URL (S3-like backends only)."""
|
||||
|
||||
|
||||
class LocalStorageBackend(StorageBackend):
|
||||
def __init__(self, storage_path: str, serve_url_prefix: str = "/uploads"):
|
||||
self.storage_path = Path(storage_path)
|
||||
self.storage_path.mkdir(parents=True, exist_ok=True)
|
||||
self.serve_url_prefix = serve_url_prefix.rstrip("/")
|
||||
|
||||
def _make_key(self, filename: str) -> str:
|
||||
ext = Path(filename).suffix
|
||||
return f"{uuid.uuid4().hex}{ext}"
|
||||
|
||||
def upload(self, file_data: bytes, filename: str, content_type: str = "") -> str:
|
||||
key = self._make_key(filename)
|
||||
dest = self.storage_path / key
|
||||
dest.write_bytes(file_data)
|
||||
return f"{self.serve_url_prefix}/{key}"
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
path = key.lstrip("/")
|
||||
if path.startswith("uploads/"):
|
||||
path = path[len("uploads/"):]
|
||||
target = self.storage_path / path
|
||||
if target.exists():
|
||||
target.unlink()
|
||||
|
||||
def generate_presigned_url(self, key: str, content_type: str = "", expires: int = 3600) -> str:
|
||||
raise NotImplementedError("Local storage does not support presigned URLs")
|
||||
|
||||
|
||||
class S3StorageBackend(StorageBackend):
|
||||
"""Compatible with MinIO, Aliyun OSS, Tencent COS, and AWS S3."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str,
|
||||
access_key: str,
|
||||
secret_key: str,
|
||||
bucket: str,
|
||||
region: str = "",
|
||||
public_url: str = "",
|
||||
):
|
||||
self.bucket = bucket
|
||||
self.public_url = public_url.rstrip("/") if public_url else ""
|
||||
kwargs: dict = {
|
||||
"endpoint_url": endpoint,
|
||||
"aws_access_key_id": access_key,
|
||||
"aws_secret_access_key": secret_key,
|
||||
"config": BotoConfig(signature_version="s3v4"),
|
||||
}
|
||||
if region:
|
||||
kwargs["region_name"] = region
|
||||
self.client = boto3.client("s3", **kwargs)
|
||||
|
||||
try:
|
||||
self.client.head_bucket(Bucket=bucket)
|
||||
except Exception:
|
||||
try:
|
||||
self.client.create_bucket(Bucket=bucket)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _make_key(self, filename: str) -> str:
|
||||
ext = Path(filename).suffix
|
||||
return f"images/{uuid.uuid4().hex}{ext}"
|
||||
|
||||
def _get_url(self, key: str) -> str:
|
||||
if self.public_url:
|
||||
return f"{self.public_url}/{key}"
|
||||
return f"{self.client.meta.endpoint_url}/{self.bucket}/{key}"
|
||||
|
||||
def upload(self, file_data: bytes, filename: str, content_type: str = "") -> str:
|
||||
key = self._make_key(filename)
|
||||
extra = {}
|
||||
if content_type:
|
||||
extra["ContentType"] = content_type
|
||||
self.client.put_object(Bucket=self.bucket, Key=key, Body=file_data, **extra)
|
||||
return self._get_url(key)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
self.client.delete_object(Bucket=self.bucket, Key=key)
|
||||
|
||||
def generate_presigned_url(self, key: str, content_type: str = "", expires: int = 3600) -> str:
|
||||
params: dict = {"Bucket": self.bucket, "Key": key}
|
||||
if content_type:
|
||||
params["ContentType"] = content_type
|
||||
return self.client.generate_presigned_url(
|
||||
"put_object", Params=params, ExpiresIn=expires
|
||||
)
|
||||
|
||||
|
||||
def get_storage_backend() -> StorageBackend:
|
||||
backend = settings.STORAGE_BACKEND.lower()
|
||||
if backend == "s3":
|
||||
return S3StorageBackend(
|
||||
endpoint=settings.S3_ENDPOINT,
|
||||
access_key=settings.S3_ACCESS_KEY,
|
||||
secret_key=settings.S3_SECRET_KEY,
|
||||
bucket=settings.S3_BUCKET,
|
||||
region=settings.S3_REGION,
|
||||
public_url=settings.S3_PUBLIC_URL,
|
||||
)
|
||||
return LocalStorageBackend(
|
||||
storage_path=settings.LOCAL_STORAGE_PATH,
|
||||
)
|
||||
|
||||
|
||||
storage: StorageBackend | None = None
|
||||
|
||||
|
||||
def init_storage() -> StorageBackend:
|
||||
global storage
|
||||
storage = get_storage_backend()
|
||||
return storage
|
||||
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
@@ -0,0 +1,36 @@
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def ensure_postgis_available() -> None:
|
||||
engine = create_engine(settings.DATABASE_URL_SYNC, echo=False)
|
||||
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
result = connection.execute(
|
||||
text("select 1 from pg_available_extensions where name = 'postgis'")
|
||||
)
|
||||
if result.scalar() != 1:
|
||||
raise RuntimeError(
|
||||
"PostGIS is not available on the PostgreSQL server. "
|
||||
"Install/enable PostGIS before starting the backend."
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def run_startup_migrations() -> None:
|
||||
ensure_postgis_available()
|
||||
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
alembic_ini = project_root / "alembic.ini"
|
||||
alembic_dir = project_root / "alembic"
|
||||
|
||||
config = Config(str(alembic_ini))
|
||||
config.set_main_option("script_location", str(alembic_dir))
|
||||
|
||||
command.upgrade(config, "head")
|
||||
@@ -0,0 +1,12 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
async_session_factory = async_sessionmaker(
|
||||
engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
# sqladmin requires a synchronous engine
|
||||
sync_engine = create_engine(settings.DATABASE_URL_SYNC, echo=False)
|
||||
@@ -0,0 +1,194 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from geoalchemy2.functions import ST_X, ST_Y
|
||||
from sqlalchemy import select
|
||||
from app.api.v1.router import v1_router
|
||||
from app.core.config import settings
|
||||
from app.core.deps import get_current_active_user, get_db
|
||||
from app.core.storage import init_storage
|
||||
from app.db.migrations import run_startup_migrations
|
||||
from app.models.spot import Spot
|
||||
from app.models.tag import Tag
|
||||
from app.models.user import User
|
||||
from app.schemas.admin import AdminSpotDetailItem
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
if settings.SENTRY_DSN:
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
||||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||
sentry_sdk.init(
|
||||
dsn=settings.SENTRY_DSN,
|
||||
integrations=[FastApiIntegration(), SqlalchemyIntegration()],
|
||||
traces_sample_rate=0.2,
|
||||
send_default_pii=False,
|
||||
)
|
||||
|
||||
from app.core.logging_config import setup_logging
|
||||
|
||||
setup_logging(
|
||||
json_format=settings.LOG_JSON,
|
||||
level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO),
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await asyncio.to_thread(run_startup_migrations)
|
||||
app.state.redis = aioredis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
app.state.storage = init_storage()
|
||||
yield
|
||||
if app.state.redis:
|
||||
await app.state.redis.aclose()
|
||||
|
||||
|
||||
app = FastAPI(title="次元取景器 API", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
|
||||
def _assert_admin_role(user: User) -> None:
|
||||
if user.role not in ("admin", "moderator"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Admin permission required",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/admin/spot-tag-options")
|
||||
async def admin_spot_tag_options_fallback(
|
||||
keyword: str = "",
|
||||
limit: int = 50,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_assert_admin_role(current_user)
|
||||
stmt = select(Tag.id, Tag.name).where(Tag.is_active.is_(True)).order_by(Tag.usage_count.desc(), Tag.id.desc()).limit(limit)
|
||||
if keyword:
|
||||
like = f"%{keyword.strip()}%"
|
||||
stmt = (
|
||||
select(Tag.id, Tag.name)
|
||||
.where(Tag.is_active.is_(True), Tag.name.ilike(like))
|
||||
.order_by(Tag.usage_count.desc(), Tag.id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = (await db.execute(stmt)).all()
|
||||
return [{"id": int(r[0]), "title": str(r[1])} for r in rows]
|
||||
|
||||
|
||||
@app.get("/api/v1/admin/spots/{spot_id}", response_model=AdminSpotDetailItem)
|
||||
async def admin_spot_detail_fallback(
|
||||
spot_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_assert_admin_role(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:
|
||||
return AdminSpotDetailItem(
|
||||
id=spot_id,
|
||||
title="",
|
||||
city="",
|
||||
longitude=0.0,
|
||||
latitude=0.0,
|
||||
description=None,
|
||||
transport=None,
|
||||
best_time=None,
|
||||
difficulty=None,
|
||||
is_free=True,
|
||||
price_min=None,
|
||||
price_max=None,
|
||||
audit_status="deleted",
|
||||
reject_reason="spot_not_found",
|
||||
creator_id=current_user.id,
|
||||
tag_ids=[],
|
||||
image_urls=[],
|
||||
images=[],
|
||||
)
|
||||
spot, lng, lat = row
|
||||
image_urls = [img.image_url for img in sorted(spot.images, key=lambda x: x.sort_order)]
|
||||
tag_ids = [tag.id for tag in spot.tags]
|
||||
return AdminSpotDetailItem(
|
||||
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,
|
||||
description=spot.description,
|
||||
transport=spot.transport,
|
||||
best_time=spot.best_time,
|
||||
difficulty=spot.difficulty,
|
||||
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,
|
||||
audit_status=spot.audit_status,
|
||||
reject_reason=spot.reject_reason,
|
||||
creator_id=spot.creator_id,
|
||||
tag_ids=tag_ids,
|
||||
image_urls=image_urls,
|
||||
images=[
|
||||
{
|
||||
"id": img.id,
|
||||
"spot_id": img.spot_id,
|
||||
"image_url": img.image_url,
|
||||
"is_cover": img.is_cover,
|
||||
"sort_order": img.sort_order,
|
||||
"created_at": img.created_at,
|
||||
}
|
||||
for img in sorted(spot.images, key=lambda x: x.sort_order)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def log_5xx_response(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
if response.status_code >= 500:
|
||||
logger.error("Server 5xx response %s %s -> %s", request.method, request.url.path, response.status_code)
|
||||
return response
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception):
|
||||
logger.error(
|
||||
"Unhandled exception on %s %s: %s",
|
||||
request.method,
|
||||
request.url.path,
|
||||
repr(exc),
|
||||
exc_info=(type(exc), exc, exc.__traceback__),
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal Server Error"},
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
if settings.STORAGE_BACKEND == "local":
|
||||
uploads_dir = Path(settings.LOCAL_STORAGE_PATH)
|
||||
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads")
|
||||
|
||||
app.include_router(v1_router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def health_check():
|
||||
return {"status": "ok", "name": "次元取景器 API"}
|
||||
@@ -0,0 +1,26 @@
|
||||
from app.models.user import User
|
||||
from app.models.spot import Spot, SpotImage
|
||||
from app.models.favorite import Favorite
|
||||
from app.models.point_ledger import PointLedger
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.comment import Comment
|
||||
from app.models.report import Report
|
||||
from app.models.rating import Rating
|
||||
from app.models.tag import Tag, SpotTag
|
||||
from app.models.correction import Correction
|
||||
from app.models.notification import Notification
|
||||
from app.models.shooting import ShootingRequest, ShootingApplication
|
||||
from app.models.event import Event, EventRegistration, EventPhoto
|
||||
from app.models.promotion import Promotion
|
||||
from app.models.membership import MembershipPlan, UserMembership
|
||||
from app.models.app_nav_config import AppNavConfig
|
||||
from app.models.system_config import SystemConfig
|
||||
|
||||
__all__ = [
|
||||
"User", "Spot", "SpotImage", "Favorite", "PointLedger", "AuditLog",
|
||||
"Comment", "Report", "Rating", "Tag", "SpotTag", "Correction", "Notification",
|
||||
"ShootingRequest", "ShootingApplication",
|
||||
"Event", "EventRegistration", "EventPhoto",
|
||||
"Promotion", "MembershipPlan", "UserMembership",
|
||||
"AppNavConfig", "SystemConfig",
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class AppNavConfig(Base):
|
||||
__tablename__ = "app_nav_configs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
key: Mapped[str] = mapped_column(String(50), nullable=False, unique=True, index=True)
|
||||
label: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
page_path: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
icon: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
active_icon: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
color: Mapped[str] = mapped_column(String(20), default="#999999")
|
||||
active_color: Mapped[str] = mapped_column(String(20), default="#6366f1")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AppNavConfig {self.key}>"
|
||||
@@ -0,0 +1,27 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
operator_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
target_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
target_id: Mapped[int | None] = mapped_column(Integer)
|
||||
detail: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
operator = relationship("User", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AuditLog {self.id} {self.action} by={self.operator_id}>"
|
||||
@@ -0,0 +1,35 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Comment(Base):
|
||||
__tablename__ = "comments"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
spot_id: Mapped[int] = mapped_column(Integer, ForeignKey("spots.id"), nullable=False, index=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("comments.id"), nullable=True)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
audit_status: Mapped[str] = mapped_column(String(20), default="approved")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user = relationship("User", lazy="selectin")
|
||||
replies = relationship(
|
||||
"Comment",
|
||||
back_populates="parent",
|
||||
lazy="noload",
|
||||
foreign_keys=[parent_id],
|
||||
)
|
||||
parent = relationship(
|
||||
"Comment",
|
||||
remote_side=[id],
|
||||
back_populates="replies",
|
||||
lazy="noload",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Comment {self.id} spot={self.spot_id}>"
|
||||
@@ -0,0 +1,27 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Correction(Base):
|
||||
__tablename__ = "corrections"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
spot_id: Mapped[int] = mapped_column(Integer, ForeignKey("spots.id"), nullable=False)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
field_name: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
suggested_value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
user = relationship("User", lazy="joined")
|
||||
spot = relationship("Spot", lazy="joined")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Correction {self.id} spot={self.spot_id} field={self.field_name}>"
|
||||
@@ -0,0 +1,74 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean, DateTime, ForeignKey, Integer,
|
||||
String, Text, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Event(Base):
|
||||
__tablename__ = "events"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
creator_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
city: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
cover_url: Mapped[str | None] = mapped_column(String(500))
|
||||
location_name: Mapped[str | None] = mapped_column(String(200))
|
||||
start_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
end_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
max_participants: Mapped[int] = mapped_column(Integer, default=0)
|
||||
spot_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("spots.id"), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="upcoming", index=True)
|
||||
audit_status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
reject_reason: Mapped[str | None] = mapped_column(String(500))
|
||||
registration_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
creator = relationship("User", lazy="selectin")
|
||||
spot = relationship("Spot", lazy="selectin")
|
||||
registrations = relationship("EventRegistration", back_populates="event", lazy="selectin")
|
||||
photos = relationship("EventPhoto", back_populates="event", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Event {self.id} {self.title}>"
|
||||
|
||||
|
||||
class EventRegistration(Base):
|
||||
__tablename__ = "event_registrations"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
event_id: Mapped[int] = mapped_column(Integer, ForeignKey("events.id"), nullable=False, index=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="registered")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
event = relationship("Event", back_populates="registrations")
|
||||
user = relationship("User", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<EventRegistration {self.id}>"
|
||||
|
||||
|
||||
class EventPhoto(Base):
|
||||
__tablename__ = "event_photos"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
event_id: Mapped[int] = mapped_column(Integer, ForeignKey("events.id"), nullable=False, index=True)
|
||||
uploader_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
image_url: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
caption: Mapped[str | None] = mapped_column(String(200))
|
||||
spot_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("spots.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
event = relationship("Event", back_populates="photos")
|
||||
uploader = relationship("User", lazy="selectin")
|
||||
spot = relationship("Spot", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<EventPhoto {self.id}>"
|
||||
@@ -0,0 +1,28 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Favorite(Base):
|
||||
__tablename__ = "favorites"
|
||||
__table_args__ = (UniqueConstraint("user_id", "spot_id", name="uq_user_spot"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
spot_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("spots.id"), nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
user = relationship("User", lazy="selectin")
|
||||
spot = relationship("Spot", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Favorite user={self.user_id} spot={self.spot_id}>"
|
||||
@@ -0,0 +1,48 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean, DateTime, ForeignKey, Integer, Numeric,
|
||||
String, Text, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class MembershipPlan(Base):
|
||||
"""会员方案"""
|
||||
__tablename__ = "membership_plans"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
duration_days: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
price: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
benefits: Mapped[str | None] = mapped_column(Text)
|
||||
extra_uploads: Mapped[int] = mapped_column(Integer, default=0)
|
||||
extra_top_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<MembershipPlan {self.id} {self.name}>"
|
||||
|
||||
|
||||
class UserMembership(Base):
|
||||
"""用户会员记录"""
|
||||
__tablename__ = "user_memberships"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
plan_id: Mapped[int] = mapped_column(Integer, ForeignKey("membership_plans.id"), nullable=False)
|
||||
start_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
end_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user = relationship("User", lazy="selectin")
|
||||
plan = relationship("MembershipPlan", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<UserMembership {self.id} user={self.user_id}>"
|
||||
@@ -0,0 +1,25 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
content: Mapped[str | None] = mapped_column(Text)
|
||||
ref_type: Mapped[str | None] = mapped_column(String(50))
|
||||
ref_id: Mapped[int | None] = mapped_column(Integer)
|
||||
is_read: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user = relationship("User", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Notification {self.id} user={self.user_id} type={self.type}>"
|
||||
@@ -0,0 +1,28 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class PointLedger(Base):
|
||||
__tablename__ = "point_ledger"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
change: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
balance: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
reason: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
ref_type: Mapped[str | None] = mapped_column(String(50))
|
||||
ref_id: Mapped[int | None] = mapped_column(Integer)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
user = relationship("User", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<PointLedger {self.id} user={self.user_id} change={self.change}>"
|
||||
@@ -0,0 +1,38 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
DateTime, ForeignKey, Integer, String, Text, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Promotion(Base):
|
||||
"""推广位/Banner"""
|
||||
__tablename__ = "promotions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
image_url: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
link_type: Mapped[str] = mapped_column(String(20), default="spot")
|
||||
link_id: Mapped[int | None] = mapped_column(Integer)
|
||||
spot_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("spots.id"), nullable=True)
|
||||
event_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("events.id"), nullable=True)
|
||||
shooting_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("shooting_requests.id"), nullable=True)
|
||||
link_url: Mapped[str | None] = mapped_column(String(500))
|
||||
position: Mapped[str] = mapped_column(String(30), default="home_banner", index=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
start_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
end_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
is_active: Mapped[bool] = mapped_column(default=True, index=True)
|
||||
impressions: Mapped[int] = mapped_column(Integer, default=0)
|
||||
clicks: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
spot = relationship("Spot", lazy="selectin")
|
||||
event = relationship("Event", lazy="selectin")
|
||||
shooting = relationship("ShootingRequest", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Promotion {self.id} {self.title}>"
|
||||
@@ -0,0 +1,23 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Rating(Base):
|
||||
__tablename__ = "ratings"
|
||||
__table_args__ = (UniqueConstraint("user_id", "spot_id", name="uq_user_spot_rating"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
spot_id: Mapped[int] = mapped_column(Integer, ForeignKey("spots.id"), nullable=False, index=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
score: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
short_comment: Mapped[str | None] = mapped_column(String(200))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user = relationship("User", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Rating {self.id} spot={self.spot_id} score={self.score}>"
|
||||
@@ -0,0 +1,27 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Report(Base):
|
||||
__tablename__ = "reports"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
reporter_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
target_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
target_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
handler_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
conclusion: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
reporter = relationship("User", foreign_keys=[reporter_id], lazy="selectin")
|
||||
handler = relationship("User", foreign_keys=[handler_id], lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Report {self.id} {self.target_type}:{self.target_id}>"
|
||||
@@ -0,0 +1,57 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean, DateTime, Float, ForeignKey, Integer, Numeric,
|
||||
String, Text, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class ShootingRequest(Base):
|
||||
__tablename__ = "shooting_requests"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
creator_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
city: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
style: Mapped[str | None] = mapped_column(String(100))
|
||||
shoot_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
is_free: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
budget_min: Mapped[float | None] = mapped_column(Numeric(10, 2))
|
||||
budget_max: Mapped[float | None] = mapped_column(Numeric(10, 2))
|
||||
role_needed: Mapped[str] = mapped_column(String(20), default="photographer")
|
||||
max_applicants: Mapped[int] = mapped_column(Integer, default=1)
|
||||
contact_info: Mapped[str | None] = mapped_column(String(200))
|
||||
spot_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("spots.id"), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="open", index=True)
|
||||
audit_status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
reject_reason: Mapped[str | None] = mapped_column(String(500))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
creator = relationship("User", lazy="selectin")
|
||||
spot = relationship("Spot", lazy="selectin")
|
||||
applications = relationship("ShootingApplication", back_populates="request", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ShootingRequest {self.id} {self.title}>"
|
||||
|
||||
|
||||
class ShootingApplication(Base):
|
||||
__tablename__ = "shooting_applications"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
request_id: Mapped[int] = mapped_column(Integer, ForeignKey("shooting_requests.id"), nullable=False, index=True)
|
||||
applicant_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
message: Mapped[str | None] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
request = relationship("ShootingRequest", back_populates="applications")
|
||||
applicant = relationship("User", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ShootingApplication {self.id} req={self.request_id}>"
|
||||
@@ -0,0 +1,88 @@
|
||||
from datetime import datetime
|
||||
|
||||
from geoalchemy2 import Geometry
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, Numeric, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Spot(Base):
|
||||
__tablename__ = "spots"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
||||
city: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
location: Mapped[str] = mapped_column(
|
||||
Geometry("POINT", srid=4326), nullable=False
|
||||
)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
transport: Mapped[str | None] = mapped_column(Text)
|
||||
best_time: Mapped[str | None] = mapped_column(String(200))
|
||||
difficulty: Mapped[str | None] = mapped_column(Text)
|
||||
is_free: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
price_min: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
|
||||
price_max: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
|
||||
audit_status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
reject_reason: Mapped[str | None] = mapped_column(String(500))
|
||||
avg_rating: Mapped[float | None] = mapped_column(Float, default=None)
|
||||
rating_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
favorite_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
creator_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
creator = relationship("User", lazy="selectin")
|
||||
images = relationship(
|
||||
"SpotImage", back_populates="spot", lazy="selectin", order_by="SpotImage.sort_order"
|
||||
)
|
||||
tags = relationship("Tag", secondary="spot_tags", lazy="selectin")
|
||||
|
||||
@property
|
||||
def longitude(self) -> float | None:
|
||||
if self.location is None:
|
||||
return None
|
||||
# WKBElement → use ST_X via a query; for serialisation we parse the WKT
|
||||
from geoalchemy2.shape import to_shape
|
||||
|
||||
point = to_shape(self.location)
|
||||
return point.x
|
||||
|
||||
@property
|
||||
def latitude(self) -> float | None:
|
||||
if self.location is None:
|
||||
return None
|
||||
from geoalchemy2.shape import to_shape
|
||||
|
||||
point = to_shape(self.location)
|
||||
return point.y
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Spot {self.id} {self.title}>"
|
||||
|
||||
|
||||
class SpotImage(Base):
|
||||
__tablename__ = "spot_images"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
spot_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("spots.id"), nullable=False
|
||||
)
|
||||
image_url: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
is_cover: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
audit_status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
spot = relationship("Spot", back_populates="images")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<SpotImage {self.id} spot={self.spot_id}>"
|
||||
@@ -0,0 +1,25 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class SystemConfig(Base):
|
||||
__tablename__ = "system_configs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
config_key: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, index=True)
|
||||
category: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
config_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
updated_by: Mapped[int | None] = mapped_column(Integer)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<SystemConfig {self.config_key}>"
|
||||
@@ -0,0 +1,30 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Tag(Base):
|
||||
__tablename__ = "tags"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
|
||||
category: Mapped[str | None] = mapped_column(String(50))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
usage_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Tag {self.id} {self.name}>"
|
||||
|
||||
|
||||
class SpotTag(Base):
|
||||
__tablename__ = "spot_tags"
|
||||
__table_args__ = (UniqueConstraint("spot_id", "tag_id", name="uq_spot_tag"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
spot_id: Mapped[int] = mapped_column(Integer, ForeignKey("spots.id"), nullable=False)
|
||||
tag_id: Mapped[int] = mapped_column(Integer, ForeignKey("tags.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,31 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(20), unique=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), unique=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
nickname: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(500))
|
||||
city: Mapped[str | None] = mapped_column(String(100))
|
||||
bio: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
identity: Mapped[str] = mapped_column(String(20), default="both")
|
||||
role: Mapped[str] = mapped_column(String(20), default="user")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User {self.id} {self.nickname}>"
|
||||
@@ -0,0 +1,608 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AdminLoginRequest(BaseModel):
|
||||
account: str = Field(min_length=1, max_length=100)
|
||||
password: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
class AdminUserOut(BaseModel):
|
||||
id: int
|
||||
nickname: str
|
||||
phone: str | None = None
|
||||
email: str | None = None
|
||||
role: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminLoginResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
user: AdminUserOut
|
||||
|
||||
|
||||
class AdminSpotListItem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
city: str
|
||||
audit_status: str
|
||||
reject_reason: str | None = None
|
||||
creator_id: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminSpotImageItem(BaseModel):
|
||||
id: int
|
||||
image_url: str
|
||||
is_cover: bool
|
||||
sort_order: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminSpotDetailItem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
city: str
|
||||
longitude: float | None = None
|
||||
latitude: float | None = None
|
||||
description: str | None = None
|
||||
transport: str | None = None
|
||||
best_time: str | None = None
|
||||
difficulty: str | None = None
|
||||
is_free: bool
|
||||
price_min: float | None = None
|
||||
price_max: float | None = None
|
||||
audit_status: str
|
||||
reject_reason: str | None = None
|
||||
creator_id: int
|
||||
tag_ids: list[int] = []
|
||||
image_urls: list[str] = []
|
||||
images: list[AdminSpotImageItem] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminSpotCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
city: str = Field(min_length=1, max_length=100)
|
||||
longitude: float
|
||||
latitude: float
|
||||
description: str | None = None
|
||||
transport: str | None = None
|
||||
best_time: str | None = Field(default=None, max_length=200)
|
||||
difficulty: str | None = None
|
||||
is_free: bool = True
|
||||
price_min: float | None = None
|
||||
price_max: float | None = None
|
||||
audit_status: str = Field(default="pending", pattern="^(pending|approved|rejected|deleted)$")
|
||||
reject_reason: str | None = Field(default=None, max_length=500)
|
||||
creator_id: int
|
||||
image_urls: list[str] = []
|
||||
tag_ids: list[int] = []
|
||||
|
||||
|
||||
class AdminSpotUpdateRequest(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
city: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
longitude: float | None = None
|
||||
latitude: float | None = None
|
||||
description: str | None = None
|
||||
transport: str | None = None
|
||||
best_time: str | None = Field(default=None, max_length=200)
|
||||
difficulty: str | None = None
|
||||
is_free: bool | None = None
|
||||
price_min: float | None = None
|
||||
price_max: float | None = None
|
||||
audit_status: str | None = Field(default=None, pattern="^(pending|approved|rejected|deleted)$")
|
||||
reject_reason: str | None = Field(default=None, max_length=500)
|
||||
creator_id: int | None = None
|
||||
image_urls: list[str] | None = None
|
||||
tag_ids: list[int] | None = None
|
||||
|
||||
|
||||
class AdminSpotAuditRequest(BaseModel):
|
||||
audit_status: str = Field(pattern="^(pending|approved|rejected|deleted)$")
|
||||
reject_reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class AdminBatchAuditRequest(BaseModel):
|
||||
ids: list[int] = Field(min_length=1)
|
||||
audit_status: str = Field(pattern="^(pending|approved|rejected|deleted)$")
|
||||
reject_reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class AdminDashboardStats(BaseModel):
|
||||
spots_total: int
|
||||
spots_pending: int
|
||||
spots_approved: int
|
||||
spots_rejected: int
|
||||
users_total: int
|
||||
events_total: int
|
||||
shooting_total: int
|
||||
|
||||
|
||||
class AdminEventListItem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
city: str
|
||||
status: str
|
||||
audit_status: str
|
||||
reject_reason: str | None = None
|
||||
creator_id: int
|
||||
registration_count: int
|
||||
max_participants: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminEventRegistrationItem(BaseModel):
|
||||
id: int
|
||||
event_id: int
|
||||
user_id: int
|
||||
user_nickname: str
|
||||
status: str
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminEventPhotoItem(BaseModel):
|
||||
id: int
|
||||
event_id: int
|
||||
uploader_id: int
|
||||
uploader_nickname: str
|
||||
image_url: str
|
||||
caption: str | None = None
|
||||
spot_id: int | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminEventPhotoInput(BaseModel):
|
||||
uploader_id: int
|
||||
image_url: str = Field(min_length=1, max_length=500)
|
||||
caption: str | None = Field(default=None, max_length=200)
|
||||
spot_id: int | None = None
|
||||
|
||||
|
||||
class AdminEventDetailItem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
city: str
|
||||
description: str | None = None
|
||||
cover_url: str | None = None
|
||||
location_name: str | None = None
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
max_participants: int
|
||||
spot_id: int | None = None
|
||||
status: str
|
||||
audit_status: str
|
||||
reject_reason: str | None = None
|
||||
registration_count: int
|
||||
creator_id: int
|
||||
registration_user_ids: list[int] = []
|
||||
photos: list[AdminEventPhotoItem] = []
|
||||
registrations: list[AdminEventRegistrationItem] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminEventCreateRequest(BaseModel):
|
||||
creator_id: int
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
city: str = Field(min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
cover_url: str | None = Field(default=None, max_length=500)
|
||||
location_name: str | None = Field(default=None, max_length=200)
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
max_participants: int = Field(default=0, ge=0)
|
||||
spot_id: int | None = None
|
||||
status: str = Field(default="upcoming", pattern="^(upcoming|ongoing|ended|cancelled)$")
|
||||
audit_status: str = Field(default="pending", pattern="^(pending|approved|rejected|deleted)$")
|
||||
reject_reason: str | None = Field(default=None, max_length=500)
|
||||
registration_user_ids: list[int] = []
|
||||
photos: list[AdminEventPhotoInput] = []
|
||||
|
||||
|
||||
class AdminEventUpdateRequest(BaseModel):
|
||||
creator_id: int | None = None
|
||||
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
city: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
cover_url: str | None = Field(default=None, max_length=500)
|
||||
location_name: str | None = Field(default=None, max_length=200)
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
max_participants: int | None = Field(default=None, ge=0)
|
||||
spot_id: int | None = None
|
||||
status: str | None = Field(default=None, pattern="^(upcoming|ongoing|ended|cancelled)$")
|
||||
audit_status: str | None = Field(default=None, pattern="^(pending|approved|rejected|deleted)$")
|
||||
reject_reason: str | None = Field(default=None, max_length=500)
|
||||
registration_user_ids: list[int] | None = None
|
||||
photos: list[AdminEventPhotoInput] | None = None
|
||||
|
||||
|
||||
class AdminEventRegistrationCreateRequest(BaseModel):
|
||||
user_id: int
|
||||
status: str = Field(default="registered", pattern="^(registered|cancelled)$")
|
||||
|
||||
|
||||
class AdminEventRegistrationUpdateRequest(BaseModel):
|
||||
status: str = Field(pattern="^(registered|cancelled)$")
|
||||
|
||||
|
||||
class AdminEventPhotoCreateRequest(BaseModel):
|
||||
uploader_id: int
|
||||
image_url: str = Field(min_length=1, max_length=500)
|
||||
caption: str | None = Field(default=None, max_length=200)
|
||||
spot_id: int | None = None
|
||||
|
||||
|
||||
class AdminEventPhotoUpdateRequest(BaseModel):
|
||||
uploader_id: int | None = None
|
||||
image_url: str | None = Field(default=None, min_length=1, max_length=500)
|
||||
caption: str | None = Field(default=None, max_length=200)
|
||||
spot_id: int | None = None
|
||||
|
||||
|
||||
class AdminShootingListItem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
city: str
|
||||
status: str
|
||||
audit_status: str
|
||||
reject_reason: str | None = None
|
||||
creator_id: int
|
||||
role_needed: str
|
||||
max_applicants: int
|
||||
is_free: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminShootingApplicationItem(BaseModel):
|
||||
id: int
|
||||
request_id: int
|
||||
applicant_id: int
|
||||
applicant_nickname: str
|
||||
message: str | None = None
|
||||
status: str
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminShootingDetailItem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
city: str
|
||||
description: str | None = None
|
||||
style: str | None = None
|
||||
shoot_date: datetime | None = None
|
||||
is_free: bool
|
||||
budget_min: float | None = None
|
||||
budget_max: float | None = None
|
||||
role_needed: str
|
||||
max_applicants: int
|
||||
contact_info: str | None = None
|
||||
spot_id: int | None = None
|
||||
status: str
|
||||
audit_status: str
|
||||
reject_reason: str | None = None
|
||||
creator_id: int
|
||||
applications: list[AdminShootingApplicationItem] = []
|
||||
application_user_ids: list[int] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminShootingApplicationInput(BaseModel):
|
||||
applicant_id: int
|
||||
message: str | None = None
|
||||
status: str = Field(default="pending", pattern="^(pending|accepted|rejected|cancelled)$")
|
||||
|
||||
|
||||
class AdminShootingCreateRequest(BaseModel):
|
||||
creator_id: int
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
city: str = Field(min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
style: str | None = Field(default=None, max_length=100)
|
||||
shoot_date: datetime | None = None
|
||||
is_free: bool = False
|
||||
budget_min: float | None = None
|
||||
budget_max: float | None = None
|
||||
role_needed: str = Field(default="photographer", pattern="^(photographer|cosplayer|both)$")
|
||||
max_applicants: int = Field(default=1, ge=1, le=100)
|
||||
contact_info: str | None = Field(default=None, max_length=200)
|
||||
spot_id: int | None = None
|
||||
status: str = Field(default="open", pattern="^(open|matched|closed|cancelled)$")
|
||||
audit_status: str = Field(default="pending", pattern="^(pending|approved|rejected|deleted)$")
|
||||
reject_reason: str | None = Field(default=None, max_length=500)
|
||||
applications: list[AdminShootingApplicationInput] = []
|
||||
|
||||
|
||||
class AdminShootingUpdateRequest(BaseModel):
|
||||
creator_id: int | None = None
|
||||
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
city: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
style: str | None = Field(default=None, max_length=100)
|
||||
shoot_date: datetime | None = None
|
||||
is_free: bool | None = None
|
||||
budget_min: float | None = None
|
||||
budget_max: float | None = None
|
||||
role_needed: str | None = Field(default=None, pattern="^(photographer|cosplayer|both)$")
|
||||
max_applicants: int | None = Field(default=None, ge=1, le=100)
|
||||
contact_info: str | None = Field(default=None, max_length=200)
|
||||
spot_id: int | None = None
|
||||
status: str | None = Field(default=None, pattern="^(open|matched|closed|cancelled)$")
|
||||
audit_status: str | None = Field(default=None, pattern="^(pending|approved|rejected|deleted)$")
|
||||
reject_reason: str | None = Field(default=None, max_length=500)
|
||||
applications: list[AdminShootingApplicationInput] | None = None
|
||||
|
||||
|
||||
class AdminShootingApplicationCreateRequest(BaseModel):
|
||||
applicant_id: int
|
||||
message: str | None = None
|
||||
status: str = Field(default="pending", pattern="^(pending|accepted|rejected|cancelled)$")
|
||||
|
||||
|
||||
class AdminShootingApplicationUpdateRequest(BaseModel):
|
||||
message: str | None = None
|
||||
status: str = Field(pattern="^(pending|accepted|rejected|cancelled)$")
|
||||
|
||||
|
||||
class AdminAuditRequest(BaseModel):
|
||||
audit_status: str = Field(pattern="^(pending|approved|rejected|deleted)$")
|
||||
reject_reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class AdminModuleCrudCoverage(BaseModel):
|
||||
create: bool
|
||||
read: bool
|
||||
update: bool
|
||||
delete: bool
|
||||
|
||||
|
||||
class AdminModuleDesignItem(BaseModel):
|
||||
module_key: str
|
||||
module_name: str
|
||||
models: list[str]
|
||||
api_endpoint_prefixes: list[str]
|
||||
coverage: AdminModuleCrudCoverage
|
||||
status: str
|
||||
notes: str
|
||||
|
||||
|
||||
class AdminModuleDesignResponse(BaseModel):
|
||||
total_modules: int
|
||||
full_covered: int
|
||||
partial_covered: int
|
||||
missing_covered: int
|
||||
items: list[AdminModuleDesignItem]
|
||||
|
||||
|
||||
class AdminUserListItem(BaseModel):
|
||||
id: int
|
||||
nickname: str
|
||||
phone: str | None = None
|
||||
email: str | None = None
|
||||
city: str | None = None
|
||||
identity: str | None = None
|
||||
role: str
|
||||
is_active: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminUserCreateRequest(BaseModel):
|
||||
nickname: str = Field(min_length=1, max_length=50)
|
||||
password: str = Field(min_length=6, max_length=200)
|
||||
phone: str | None = Field(default=None, max_length=20)
|
||||
email: str | None = Field(default=None, max_length=255)
|
||||
city: str | None = Field(default=None, max_length=100)
|
||||
identity: str = Field(default="both", pattern="^(photographer|cosplayer|both)$")
|
||||
role: str = Field(default="user", pattern="^(user|moderator|admin)$")
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class AdminUserUpdateRequest(BaseModel):
|
||||
nickname: str | None = Field(default=None, min_length=1, max_length=50)
|
||||
phone: str | None = Field(default=None, max_length=20)
|
||||
email: str | None = Field(default=None, max_length=255)
|
||||
city: str | None = Field(default=None, max_length=100)
|
||||
identity: str | None = Field(default=None, pattern="^(photographer|cosplayer|both)$")
|
||||
role: str | None = Field(default=None, pattern="^(user|moderator|admin)$")
|
||||
is_active: bool | None = None
|
||||
password: str | None = Field(default=None, min_length=6, max_length=200)
|
||||
|
||||
|
||||
class AdminMembershipPlanItem(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
duration_days: int
|
||||
price: float
|
||||
benefits: str | None = None
|
||||
extra_uploads: int
|
||||
extra_top_count: int
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminMembershipPlanCreateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
duration_days: int = Field(ge=1, le=3650)
|
||||
price: float = Field(ge=0)
|
||||
benefits: str | None = None
|
||||
extra_uploads: int = Field(default=0, ge=0)
|
||||
extra_top_count: int = Field(default=0, ge=0)
|
||||
is_active: bool = True
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class AdminMembershipPlanUpdateRequest(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
duration_days: int | None = Field(default=None, ge=1, le=3650)
|
||||
price: float | None = Field(default=None, ge=0)
|
||||
benefits: str | None = None
|
||||
extra_uploads: int | None = Field(default=None, ge=0)
|
||||
extra_top_count: int | None = Field(default=None, ge=0)
|
||||
is_active: bool | None = None
|
||||
sort_order: int | None = None
|
||||
|
||||
|
||||
class AdminUserMembershipItem(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
user_nickname: str
|
||||
plan_id: int
|
||||
plan_name: str
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
is_active: bool
|
||||
|
||||
|
||||
class AdminUserMembershipCreateRequest(BaseModel):
|
||||
user_id: int
|
||||
plan_id: int
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class AdminUserMembershipUpdateRequest(BaseModel):
|
||||
plan_id: int | None = None
|
||||
start_date: datetime | None = None
|
||||
end_date: datetime | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class AdminPointLedgerItem(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
user_nickname: str
|
||||
change: int
|
||||
balance: int
|
||||
reason: str
|
||||
ref_type: str | None = None
|
||||
ref_id: int | None = None
|
||||
rolled_back: bool = False
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AdminPointAdjustRequest(BaseModel):
|
||||
user_id: int
|
||||
change: int = Field(ne=0)
|
||||
reason: str = Field(min_length=1, max_length=200)
|
||||
ref_type: str | None = Field(default=None, max_length=50)
|
||||
ref_id: int | None = None
|
||||
|
||||
|
||||
class AdminNotificationItem(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
user_nickname: str
|
||||
type: str
|
||||
title: str
|
||||
content: str | None = None
|
||||
ref_type: str | None = None
|
||||
ref_id: int | None = None
|
||||
is_read: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AdminNotificationCreateRequest(BaseModel):
|
||||
user_id: int
|
||||
type: str = Field(min_length=1, max_length=50)
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
content: str | None = None
|
||||
ref_type: str | None = Field(default=None, max_length=50)
|
||||
ref_id: int | None = None
|
||||
|
||||
|
||||
class AdminNotificationUpdateRequest(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
content: str | None = None
|
||||
is_read: bool | None = None
|
||||
|
||||
|
||||
class AdminAuditLogItem(BaseModel):
|
||||
id: int
|
||||
operator_id: int
|
||||
operator_nickname: str
|
||||
action: str
|
||||
target_type: str
|
||||
target_id: int | None = None
|
||||
detail: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AdminAuditLogCreateRequest(BaseModel):
|
||||
action: str = Field(min_length=1, max_length=100)
|
||||
target_type: str = Field(min_length=1, max_length=50)
|
||||
target_id: int | None = None
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
class AdminReportItem(BaseModel):
|
||||
id: int
|
||||
reporter_id: int
|
||||
reporter_nickname: str
|
||||
target_type: str
|
||||
target_id: int
|
||||
reason: str
|
||||
status: str
|
||||
handler_id: int | None = None
|
||||
handler_nickname: str | None = None
|
||||
conclusion: str | None = None
|
||||
created_at: datetime
|
||||
resolved_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminReportUpdateRequest(BaseModel):
|
||||
status: str | None = Field(default=None, pattern="^(pending|processing|resolved|rejected)$")
|
||||
handler_id: int | None = None
|
||||
conclusion: str | None = None
|
||||
|
||||
|
||||
class AdminSystemConfigItem(BaseModel):
|
||||
id: int
|
||||
config_key: str
|
||||
category: str
|
||||
title: str
|
||||
config_json: str
|
||||
description: str | None = None
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
updated_by: int | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AdminSystemConfigCreateRequest(BaseModel):
|
||||
config_key: str = Field(min_length=1, max_length=100)
|
||||
category: str = Field(min_length=1, max_length=50)
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
config_json: str = Field(min_length=2)
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class AdminSystemConfigUpdateRequest(BaseModel):
|
||||
category: str | None = Field(default=None, min_length=1, max_length=50)
|
||||
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
config_json: str | None = Field(default=None, min_length=2)
|
||||
description: str | None = None
|
||||
is_active: bool | None = None
|
||||
sort_order: int | None = None
|
||||
@@ -0,0 +1,42 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AppNavConfigOut(BaseModel):
|
||||
id: int
|
||||
key: str
|
||||
label: str
|
||||
page_path: str
|
||||
icon: str
|
||||
active_icon: str
|
||||
color: str
|
||||
active_color: str
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
updated_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AppNavConfigCreate(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=50)
|
||||
label: str = Field(min_length=1, max_length=50)
|
||||
page_path: str = Field(min_length=1, max_length=200)
|
||||
icon: str = Field(min_length=1, max_length=50)
|
||||
active_icon: str = Field(min_length=1, max_length=50)
|
||||
color: str = Field(min_length=4, max_length=20)
|
||||
active_color: str = Field(min_length=4, max_length=20)
|
||||
is_active: bool = True
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class AppNavConfigUpdate(BaseModel):
|
||||
label: str | None = Field(default=None, min_length=1, max_length=50)
|
||||
page_path: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
icon: str | None = Field(default=None, min_length=1, max_length=50)
|
||||
active_icon: str | None = Field(default=None, min_length=1, max_length=50)
|
||||
color: str | None = Field(default=None, min_length=4, max_length=20)
|
||||
active_color: str | None = Field(default=None, min_length=4, max_length=20)
|
||||
is_active: bool | None = None
|
||||
sort_order: int | None = None
|
||||
@@ -0,0 +1,26 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.user import UserInfo
|
||||
|
||||
|
||||
class CommentCreate(BaseModel):
|
||||
content: str = Field(..., min_length=1, max_length=500)
|
||||
parent_id: int | None = None
|
||||
|
||||
|
||||
class CommentOut(BaseModel):
|
||||
id: int
|
||||
spot_id: int
|
||||
user: UserInfo | None = None
|
||||
parent_id: int | None = None
|
||||
content: str
|
||||
created_at: datetime | None = None
|
||||
replies: list["CommentOut"] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ReportCreate(BaseModel):
|
||||
reason: str = Field(..., min_length=1, max_length=500)
|
||||
@@ -0,0 +1,20 @@
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class ResponseBase(BaseModel):
|
||||
code: int = 0
|
||||
message: str = "success"
|
||||
|
||||
|
||||
class PageParams(BaseModel):
|
||||
page: int = 1
|
||||
page_size: int = Field(default=20, le=100)
|
||||
|
||||
|
||||
class PageResponse(ResponseBase, Generic[T]):
|
||||
total: int
|
||||
items: list[T]
|
||||
@@ -0,0 +1,24 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.user import UserInfo
|
||||
|
||||
|
||||
class CorrectionCreate(BaseModel):
|
||||
field_name: str = Field(..., max_length=50)
|
||||
suggested_value: str = Field(..., min_length=1)
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class CorrectionOut(BaseModel):
|
||||
id: int
|
||||
spot_id: int
|
||||
user: UserInfo | None = None
|
||||
field_name: str
|
||||
suggested_value: str
|
||||
reason: str | None = None
|
||||
status: str
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -0,0 +1,82 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.user import UserInfo
|
||||
|
||||
|
||||
class EventCreate(BaseModel):
|
||||
title: str
|
||||
city: str
|
||||
description: str | None = None
|
||||
cover_url: str | None = None
|
||||
location_name: str | None = None
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
max_participants: int = 0
|
||||
spot_id: int | None = None
|
||||
|
||||
|
||||
class EventUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
city: str | None = None
|
||||
description: str | None = None
|
||||
cover_url: str | None = None
|
||||
location_name: str | None = None
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
max_participants: int | None = None
|
||||
spot_id: int | None = None
|
||||
|
||||
|
||||
class EventBrief(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
city: str
|
||||
cover_url: str | None = None
|
||||
location_name: str | None = None
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
status: str = "upcoming"
|
||||
audit_status: str = "pending"
|
||||
creator: UserInfo | None = None
|
||||
registration_count: int = 0
|
||||
max_participants: int = 0
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class EventDetail(EventBrief):
|
||||
description: str | None = None
|
||||
spot_id: int | None = None
|
||||
reject_reason: str | None = None
|
||||
has_registered: bool = False
|
||||
photos: list["EventPhotoOut"] = []
|
||||
|
||||
|
||||
class EventPhotoOut(BaseModel):
|
||||
id: int
|
||||
image_url: str
|
||||
caption: str | None = None
|
||||
uploader: UserInfo | None = None
|
||||
spot_id: int | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class EventPhotoCreate(BaseModel):
|
||||
image_url: str
|
||||
caption: str | None = None
|
||||
spot_id: int | None = None
|
||||
|
||||
|
||||
class RegistrationOut(BaseModel):
|
||||
id: int
|
||||
event_id: int
|
||||
user: UserInfo | None = None
|
||||
status: str = "registered"
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -0,0 +1,31 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MembershipPlanOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
duration_days: int
|
||||
price: float
|
||||
benefits: str | None = None
|
||||
extra_uploads: int = 0
|
||||
extra_top_count: int = 0
|
||||
sort_order: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserMembershipOut(BaseModel):
|
||||
id: int
|
||||
plan: MembershipPlanOut | None = None
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
is_active: bool = True
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PurchaseMembership(BaseModel):
|
||||
plan_id: int
|
||||
@@ -0,0 +1,19 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PointBalance(BaseModel):
|
||||
balance: int
|
||||
|
||||
|
||||
class PointRecord(BaseModel):
|
||||
id: int
|
||||
change: int
|
||||
balance: int
|
||||
reason: str
|
||||
ref_type: str | None = None
|
||||
ref_id: int | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -0,0 +1,60 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PromotionOut(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
image_url: str
|
||||
link_type: str = "spot"
|
||||
link_id: int | None = None
|
||||
link_url: str | None = None
|
||||
position: str = "home_banner"
|
||||
sort_order: int = 0
|
||||
spot_id: int | None = None
|
||||
event_id: int | None = None
|
||||
shooting_id: int | None = None
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
is_active: bool = True
|
||||
impressions: int = 0
|
||||
clicks: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PromotionClick(BaseModel):
|
||||
promotion_id: int
|
||||
|
||||
|
||||
class PromotionCreate(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
image_url: str = Field(min_length=1, max_length=500)
|
||||
link_type: str = Field(pattern="^(spot|event|shooting|url)$")
|
||||
link_id: int | None = None
|
||||
spot_id: int | None = None
|
||||
event_id: int | None = None
|
||||
shooting_id: int | None = None
|
||||
link_url: str | None = Field(default=None, max_length=500)
|
||||
position: str = Field(default="home_banner", min_length=1, max_length=30)
|
||||
sort_order: int = 0
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class PromotionUpdate(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
image_url: str | None = Field(default=None, min_length=1, max_length=500)
|
||||
link_type: str | None = Field(default=None, pattern="^(spot|event|shooting|url)$")
|
||||
link_id: int | None = None
|
||||
spot_id: int | None = None
|
||||
event_id: int | None = None
|
||||
shooting_id: int | None = None
|
||||
link_url: str | None = Field(default=None, max_length=500)
|
||||
position: str | None = Field(default=None, min_length=1, max_length=30)
|
||||
sort_order: int | None = None
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
is_active: bool | None = None
|
||||
@@ -0,0 +1,21 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.user import UserInfo
|
||||
|
||||
|
||||
class RatingCreate(BaseModel):
|
||||
score: int = Field(..., ge=1, le=5)
|
||||
short_comment: str | None = None
|
||||
|
||||
|
||||
class RatingOut(BaseModel):
|
||||
id: int
|
||||
spot_id: int
|
||||
user: UserInfo | None = None
|
||||
score: int
|
||||
short_comment: str | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -0,0 +1,78 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.user import UserInfo
|
||||
|
||||
|
||||
class ShootingRequestCreate(BaseModel):
|
||||
title: str
|
||||
city: str
|
||||
description: str | None = None
|
||||
style: str | None = None
|
||||
shoot_date: datetime | None = None
|
||||
is_free: bool = False
|
||||
budget_min: float | None = None
|
||||
budget_max: float | None = None
|
||||
role_needed: str = "photographer"
|
||||
max_applicants: int = 1
|
||||
contact_info: str | None = None
|
||||
spot_id: int | None = None
|
||||
|
||||
|
||||
class ShootingRequestUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
city: str | None = None
|
||||
description: str | None = None
|
||||
style: str | None = None
|
||||
shoot_date: datetime | None = None
|
||||
is_free: bool | None = None
|
||||
budget_min: float | None = None
|
||||
budget_max: float | None = None
|
||||
role_needed: str | None = None
|
||||
max_applicants: int | None = None
|
||||
contact_info: str | None = None
|
||||
spot_id: int | None = None
|
||||
|
||||
|
||||
class ShootingRequestBrief(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
city: str
|
||||
style: str | None = None
|
||||
shoot_date: datetime | None = None
|
||||
is_free: bool = False
|
||||
budget_min: float | None = None
|
||||
budget_max: float | None = None
|
||||
role_needed: str = "photographer"
|
||||
status: str = "open"
|
||||
audit_status: str = "pending"
|
||||
creator: UserInfo | None = None
|
||||
application_count: int = 0
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ShootingRequestDetail(ShootingRequestBrief):
|
||||
description: str | None = None
|
||||
max_applicants: int = 1
|
||||
contact_info: str | None = None
|
||||
spot_id: int | None = None
|
||||
reject_reason: str | None = None
|
||||
has_applied: bool = False
|
||||
|
||||
|
||||
class ApplicationCreate(BaseModel):
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class ApplicationOut(BaseModel):
|
||||
id: int
|
||||
request_id: int
|
||||
applicant: UserInfo | None = None
|
||||
message: str | None = None
|
||||
status: str = "pending"
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -0,0 +1,82 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.tag import TagOut
|
||||
from app.schemas.user import UserInfo
|
||||
|
||||
|
||||
class SpotCreate(BaseModel):
|
||||
title: str
|
||||
city: str
|
||||
longitude: float
|
||||
latitude: float
|
||||
description: str | None = None
|
||||
transport: str | None = None
|
||||
best_time: str | None = None
|
||||
difficulty: str | None = None
|
||||
is_free: bool = True
|
||||
price_min: float | None = None
|
||||
price_max: float | None = None
|
||||
image_urls: list[str] = []
|
||||
tag_ids: list[int] = []
|
||||
|
||||
|
||||
class SpotUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
city: str | None = None
|
||||
longitude: float | None = None
|
||||
latitude: float | None = None
|
||||
description: str | None = None
|
||||
transport: str | None = None
|
||||
best_time: str | None = None
|
||||
difficulty: str | None = None
|
||||
is_free: bool | None = None
|
||||
price_min: float | None = None
|
||||
price_max: float | None = None
|
||||
tag_ids: list[int] | None = None
|
||||
|
||||
|
||||
class SpotImageOut(BaseModel):
|
||||
id: int
|
||||
image_url: str
|
||||
is_cover: bool
|
||||
sort_order: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SpotBrief(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
city: str
|
||||
longitude: float | None = None
|
||||
latitude: float | None = None
|
||||
cover_image_url: str | None = None
|
||||
audit_status: str
|
||||
avg_rating: float | None = None
|
||||
favorite_count: int = 0
|
||||
is_free: bool = True
|
||||
price_min: float | None = None
|
||||
price_max: float | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SpotDetail(SpotBrief):
|
||||
description: str | None = None
|
||||
transport: str | None = None
|
||||
best_time: str | None = None
|
||||
difficulty: str | None = None
|
||||
creator: UserInfo | None = None
|
||||
images: list[SpotImageOut] = []
|
||||
tags: list[TagOut] = []
|
||||
rating_count: int = 0
|
||||
is_favorited: bool = False
|
||||
|
||||
|
||||
class SpotImageCreate(BaseModel):
|
||||
image_url: str
|
||||
is_cover: bool = False
|
||||
sort_order: int = 0
|
||||
@@ -0,0 +1,15 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TagOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
category: str | None = None
|
||||
usage_count: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TagCreate(BaseModel):
|
||||
name: str
|
||||
category: str | None = None
|
||||
@@ -0,0 +1,17 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UploadResponse(BaseModel):
|
||||
url: str
|
||||
filename: str
|
||||
|
||||
|
||||
class PresignedUrlRequest(BaseModel):
|
||||
filename: str
|
||||
content_type: str = "image/jpeg"
|
||||
|
||||
|
||||
class PresignedUrlResponse(BaseModel):
|
||||
upload_url: str
|
||||
file_key: str
|
||||
public_url: str
|
||||
@@ -0,0 +1,47 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class UserRegister(BaseModel):
|
||||
phone: str | None = None
|
||||
email: str | None = None
|
||||
password: str = Field(..., min_length=6)
|
||||
nickname: str = Field(..., min_length=1, max_length=50)
|
||||
city: str | None = None
|
||||
identity: str | None = "both"
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
account: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
id: int
|
||||
nickname: str
|
||||
avatar_url: str | None = None
|
||||
city: str | None = None
|
||||
bio: str | None = None
|
||||
identity: str | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
nickname: str | None = None
|
||||
avatar_url: str | None = None
|
||||
city: str | None = None
|
||||
bio: str | None = None
|
||||
identity: str | None = None
|
||||
@@ -0,0 +1,29 @@
|
||||
import json
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
|
||||
async def log_action(
|
||||
db: AsyncSession,
|
||||
operator_id: int,
|
||||
action: str,
|
||||
target_type: str,
|
||||
target_id: int | None = None,
|
||||
detail: dict | str | None = None,
|
||||
) -> AuditLog:
|
||||
detail_str = None
|
||||
if detail is not None:
|
||||
detail_str = json.dumps(detail, ensure_ascii=False) if isinstance(detail, dict) else str(detail)
|
||||
|
||||
entry = AuditLog(
|
||||
operator_id=operator_id,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
detail=detail_str,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
return entry
|
||||
@@ -0,0 +1,65 @@
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SENSITIVE_WORDS: list[str] = []
|
||||
_PATTERN: re.Pattern | None = None
|
||||
|
||||
|
||||
def _load_words():
|
||||
global _SENSITIVE_WORDS, _PATTERN
|
||||
words_file = Path(__file__).parent.parent.parent / "sensitive_words.txt"
|
||||
if words_file.exists():
|
||||
raw = words_file.read_text(encoding="utf-8")
|
||||
_SENSITIVE_WORDS = [w.strip() for w in raw.splitlines() if w.strip()]
|
||||
else:
|
||||
_SENSITIVE_WORDS = []
|
||||
|
||||
if _SENSITIVE_WORDS:
|
||||
escaped = [re.escape(w) for w in _SENSITIVE_WORDS]
|
||||
_PATTERN = re.compile("|".join(escaped), re.IGNORECASE)
|
||||
else:
|
||||
_PATTERN = None
|
||||
|
||||
|
||||
_load_words()
|
||||
|
||||
|
||||
def reload_sensitive_words():
|
||||
"""Hot-reload the sensitive word list from disk."""
|
||||
_load_words()
|
||||
logger.info("Reloaded %d sensitive words", len(_SENSITIVE_WORDS))
|
||||
|
||||
|
||||
def check_text(text: str) -> dict:
|
||||
"""
|
||||
Check text against the sensitive word list.
|
||||
Returns {"safe": True/False, "matched": [...]}
|
||||
"""
|
||||
if not text or _PATTERN is None:
|
||||
return {"safe": True, "matched": []}
|
||||
|
||||
matches = _PATTERN.findall(text)
|
||||
if matches:
|
||||
unique = list(set(matches))
|
||||
return {"safe": False, "matched": unique}
|
||||
return {"safe": True, "matched": []}
|
||||
|
||||
|
||||
def filter_text(text: str, replacement: str = "**") -> str:
|
||||
"""Replace sensitive words with the replacement string."""
|
||||
if not text or _PATTERN is None:
|
||||
return text
|
||||
return _PATTERN.sub(replacement, text)
|
||||
|
||||
|
||||
async def check_image_safety(image_url: str) -> dict:
|
||||
"""
|
||||
Placeholder for third-party image audit.
|
||||
In production, integrate with Tencent Cloud / Aliyun content moderation API.
|
||||
Returns {"safe": True/False, "labels": [...]}
|
||||
"""
|
||||
logger.debug("Image safety check (placeholder): %s", image_url)
|
||||
return {"safe": True, "labels": []}
|
||||
@@ -0,0 +1,25 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.notification import Notification
|
||||
|
||||
|
||||
async def send_notification(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
type: str,
|
||||
title: str,
|
||||
content: str | None = None,
|
||||
ref_type: str | None = None,
|
||||
ref_id: int | None = None,
|
||||
):
|
||||
n = Notification(
|
||||
user_id=user_id,
|
||||
type=type,
|
||||
title=title,
|
||||
content=content,
|
||||
ref_type=ref_type,
|
||||
ref_id=ref_id,
|
||||
)
|
||||
db.add(n)
|
||||
await db.flush()
|
||||
return n
|
||||
@@ -0,0 +1,34 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.point_ledger import PointLedger
|
||||
|
||||
|
||||
async def grant_points(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
change: int,
|
||||
reason: str,
|
||||
ref_type: str | None = None,
|
||||
ref_id: int | None = None,
|
||||
) -> PointLedger:
|
||||
last_entry = await db.execute(
|
||||
select(PointLedger)
|
||||
.where(PointLedger.user_id == user_id)
|
||||
.order_by(PointLedger.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
last = last_entry.scalar_one_or_none()
|
||||
current_balance = last.balance if last else 0
|
||||
|
||||
ledger = PointLedger(
|
||||
user_id=user_id,
|
||||
change=change,
|
||||
balance=current_balance + change,
|
||||
reason=reason,
|
||||
ref_type=ref_type,
|
||||
ref_id=ref_id,
|
||||
)
|
||||
db.add(ledger)
|
||||
await db.flush()
|
||||
return ledger
|
||||
@@ -0,0 +1,2 @@
|
||||
from app.tasks.image_tasks import generate_thumbnail
|
||||
from app.tasks.stats_tasks import aggregate_daily_stats
|
||||
@@ -0,0 +1,46 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from app.celery_app import celery_app
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
THUMB_SIZES = [(400, 400), (200, 200)]
|
||||
|
||||
|
||||
@celery_app.task(bind=True, max_retries=3, default_retry_delay=10)
|
||||
def generate_thumbnail(self, image_path: str):
|
||||
"""Generate thumbnail variants for an uploaded image."""
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
if settings.STORAGE_BACKEND != "local":
|
||||
logger.info("Thumbnail generation skipped for non-local storage: %s", image_path)
|
||||
return {"status": "skipped", "reason": "non-local storage"}
|
||||
|
||||
full_path = Path(settings.LOCAL_STORAGE_PATH) / image_path.lstrip("/")
|
||||
if not full_path.exists():
|
||||
logger.warning("Image not found: %s", full_path)
|
||||
return {"status": "error", "reason": "file not found"}
|
||||
|
||||
results = []
|
||||
img = Image.open(full_path)
|
||||
if img.mode in ("RGBA", "P"):
|
||||
img = img.convert("RGB")
|
||||
|
||||
for w, h in THUMB_SIZES:
|
||||
thumb = img.copy()
|
||||
thumb.thumbnail((w, h), Image.LANCZOS)
|
||||
suffix = full_path.suffix or ".jpg"
|
||||
thumb_name = f"{full_path.stem}_{w}x{h}{suffix}"
|
||||
thumb_path = full_path.parent / thumb_name
|
||||
thumb.save(str(thumb_path), quality=85, optimize=True)
|
||||
results.append(str(thumb_path))
|
||||
logger.info("Generated thumbnail: %s", thumb_path)
|
||||
|
||||
return {"status": "ok", "thumbnails": results}
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Thumbnail generation failed for %s", image_path)
|
||||
raise self.retry(exc=exc)
|
||||
@@ -0,0 +1,55 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def aggregate_daily_stats():
|
||||
"""Aggregate daily statistics and store to Redis for dashboard consumption."""
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import sync_engine
|
||||
from app.models.comment import Comment
|
||||
from app.models.event import Event
|
||||
from app.models.favorite import Favorite
|
||||
from app.models.rating import Rating
|
||||
from app.models.shooting import ShootingRequest
|
||||
from app.models.spot import Spot
|
||||
from app.models.user import User
|
||||
|
||||
try:
|
||||
with Session(sync_engine) as session:
|
||||
stats = {
|
||||
"users": session.execute(select(func.count(User.id))).scalar() or 0,
|
||||
"spots": session.execute(select(func.count(Spot.id))).scalar() or 0,
|
||||
"approved_spots": session.execute(
|
||||
select(func.count(Spot.id)).where(Spot.audit_status == "approved")
|
||||
).scalar() or 0,
|
||||
"comments": session.execute(select(func.count(Comment.id))).scalar() or 0,
|
||||
"ratings": session.execute(select(func.count(Rating.id))).scalar() or 0,
|
||||
"favorites": session.execute(select(func.count(Favorite.id))).scalar() or 0,
|
||||
"shootings": session.execute(select(func.count(ShootingRequest.id))).scalar() or 0,
|
||||
"events": session.execute(select(func.count(Event.id))).scalar() or 0,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
import redis
|
||||
r = redis.from_url(str(_get_redis_url()))
|
||||
r.hset("ciyuan:daily_stats", mapping={k: str(v) for k, v in stats.items()})
|
||||
r.expire("ciyuan:daily_stats", 86400 * 2)
|
||||
|
||||
logger.info("Daily stats aggregated: %s", stats)
|
||||
return stats
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to aggregate daily stats")
|
||||
raise
|
||||
|
||||
|
||||
def _get_redis_url():
|
||||
from app.core.config import settings
|
||||
return settings.REDIS_URL
|
||||
Reference in New Issue
Block a user