Files
CosScene/server/app/api/v1/endpoints/admin.py
T
2026-05-09 16:40:29 +08:00

2695 lines
100 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import csv
from datetime import datetime, timezone
from io import StringIO
from fastapi import APIRouter, Depends, HTTPException, Query, status
from geoalchemy2.functions import ST_X, ST_Y
from sqlalchemy import func, or_, select
from sqlalchemy import delete as sa_delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_active_user, get_db
from app.core.security import create_access_token, create_refresh_token, get_password_hash, verify_password
from app.models.spot import Spot, SpotImage
from app.models.event import Event, EventPhoto, EventRegistration
from app.models.app_nav_config import AppNavConfig
from app.models.membership import MembershipPlan, UserMembership
from app.models.notification import Notification
from app.models.point_ledger import PointLedger
from app.models.promotion import Promotion
from app.models.report import Report
from app.models.shooting import ShootingApplication, ShootingRequest
from app.models.system_config import SystemConfig
from app.models.tag import SpotTag, Tag
from app.models.user import User
from app.models.audit_log import AuditLog
from app.schemas.admin import (
AdminAuditLogCreateRequest,
AdminAuditLogItem,
AdminAuditRequest,
AdminBatchAuditRequest,
AdminDashboardStats,
AdminEventCreateRequest,
AdminEventDetailItem,
AdminEventListItem,
AdminEventPhotoCreateRequest,
AdminEventPhotoItem,
AdminEventPhotoUpdateRequest,
AdminEventRegistrationCreateRequest,
AdminEventRegistrationItem,
AdminEventRegistrationUpdateRequest,
AdminEventUpdateRequest,
AdminLoginRequest,
AdminLoginResponse,
AdminMembershipPlanCreateRequest,
AdminMembershipPlanItem,
AdminMembershipPlanUpdateRequest,
AdminModuleCrudCoverage,
AdminModuleDesignItem,
AdminModuleDesignResponse,
AdminPointAdjustRequest,
AdminPointLedgerItem,
AdminNotificationCreateRequest,
AdminNotificationItem,
AdminNotificationUpdateRequest,
AdminReportItem,
AdminReportUpdateRequest,
AdminShootingListItem,
AdminShootingApplicationCreateRequest,
AdminShootingApplicationItem,
AdminShootingApplicationUpdateRequest,
AdminShootingCreateRequest,
AdminShootingDetailItem,
AdminShootingUpdateRequest,
AdminSpotCreateRequest,
AdminSpotDetailItem,
AdminSpotImageItem,
AdminSpotAuditRequest,
AdminSpotListItem,
AdminSpotUpdateRequest,
AdminSystemConfigCreateRequest,
AdminSystemConfigItem,
AdminSystemConfigUpdateRequest,
AdminUserOut,
AdminUserCreateRequest,
AdminUserListItem,
AdminUserMembershipCreateRequest,
AdminUserMembershipItem,
AdminUserMembershipUpdateRequest,
AdminUserUpdateRequest,
)
from app.schemas.app_nav_config import AppNavConfigCreate, AppNavConfigOut, AppNavConfigUpdate
from app.schemas.common import PageResponse
from app.schemas.promotion import PromotionCreate, PromotionOut, PromotionUpdate
router = APIRouter()
def _assert_admin_role(user: User) -> None:
if user.role not in ("admin", "moderator"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin permission required",
)
def _build_csv_content(headers: list[str], rows: list[list[object]]) -> str:
buffer = StringIO()
writer = csv.writer(buffer)
writer.writerow(headers)
for row in rows:
writer.writerow(row)
return buffer.getvalue()
def _normalize_image_urls(image_urls: list[str] | None) -> list[str]:
if not image_urls:
return []
normalized = []
for item in image_urls:
value = (item or "").strip()
if value:
normalized.append(value)
return normalized
async def _recount_tag_usage(db: AsyncSession, tag_ids: set[int]) -> None:
if not tag_ids:
return
for tag_id in tag_ids:
count_stmt = select(func.count(SpotTag.id)).where(SpotTag.tag_id == tag_id)
usage_count = (await db.execute(count_stmt)).scalar() or 0
tag_result = await db.execute(select(Tag).where(Tag.id == tag_id))
tag = tag_result.scalar_one_or_none()
if tag:
tag.usage_count = int(usage_count)
def _spot_to_admin_detail(spot: Spot, lng: float | None = None, lat: float | None = None) -> AdminSpotDetailItem:
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=[AdminSpotImageItem.model_validate(i) for i in sorted(spot.images, key=lambda x: x.sort_order)],
)
def _event_registration_item(registration: EventRegistration) -> AdminEventRegistrationItem:
return AdminEventRegistrationItem(
id=registration.id,
event_id=registration.event_id,
user_id=registration.user_id,
user_nickname=registration.user.nickname if registration.user else "",
status=registration.status,
created_at=registration.created_at,
)
def _event_photo_item(photo: EventPhoto) -> AdminEventPhotoItem:
return AdminEventPhotoItem(
id=photo.id,
event_id=photo.event_id,
uploader_id=photo.uploader_id,
uploader_nickname=photo.uploader.nickname if photo.uploader else "",
image_url=photo.image_url,
caption=photo.caption,
spot_id=photo.spot_id,
created_at=photo.created_at,
)
def _event_to_admin_detail(event: Event) -> AdminEventDetailItem:
registrations = sorted(event.registrations, key=lambda x: x.created_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
photos = sorted(event.photos, key=lambda x: x.created_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
return AdminEventDetailItem(
id=event.id,
title=event.title,
city=event.city,
description=event.description,
cover_url=event.cover_url,
location_name=event.location_name,
start_time=event.start_time,
end_time=event.end_time,
max_participants=event.max_participants,
spot_id=event.spot_id,
status=event.status,
audit_status=event.audit_status,
reject_reason=event.reject_reason,
registration_count=event.registration_count,
creator_id=event.creator_id,
registration_user_ids=[reg.user_id for reg in registrations if reg.status == "registered"],
photos=[_event_photo_item(photo) for photo in photos],
registrations=[_event_registration_item(reg) for reg in registrations],
)
def _shooting_application_item(application: ShootingApplication) -> AdminShootingApplicationItem:
return AdminShootingApplicationItem(
id=application.id,
request_id=application.request_id,
applicant_id=application.applicant_id,
applicant_nickname=application.applicant.nickname if application.applicant else "",
message=application.message,
status=application.status,
created_at=application.created_at,
)
def _shooting_to_admin_detail(request: ShootingRequest) -> AdminShootingDetailItem:
apps = sorted(request.applications, key=lambda x: x.created_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
return AdminShootingDetailItem(
id=request.id,
title=request.title,
city=request.city,
description=request.description,
style=request.style,
shoot_date=request.shoot_date,
is_free=request.is_free,
budget_min=float(request.budget_min) if request.budget_min is not None else None,
budget_max=float(request.budget_max) if request.budget_max is not None else None,
role_needed=request.role_needed,
max_applicants=request.max_applicants,
contact_info=request.contact_info,
spot_id=request.spot_id,
status=request.status,
audit_status=request.audit_status,
reject_reason=request.reject_reason,
creator_id=request.creator_id,
applications=[_shooting_application_item(item) for item in apps],
application_user_ids=[item.applicant_id for item in apps],
)
async def _sync_shooting_applications(
db: AsyncSession,
request: ShootingRequest,
applications_payload: list[dict],
) -> None:
await db.execute(sa_delete(ShootingApplication).where(ShootingApplication.request_id == request.id))
if not applications_payload:
return
applicant_ids = {int(item["applicant_id"]) for item in applications_payload}
user_rows = (await db.execute(select(User.id).where(User.id.in_(applicant_ids)))).scalars().all()
valid_user_ids = {int(user_id) for user_id in user_rows}
for item in applications_payload:
applicant_id = int(item["applicant_id"])
if applicant_id not in valid_user_ids:
continue
db.add(
ShootingApplication(
request_id=request.id,
applicant_id=applicant_id,
message=item.get("message"),
status=item.get("status") or "pending",
)
)
async def _sync_event_registrations(
db: AsyncSession,
event: Event,
registration_user_ids: list[int],
) -> None:
requested_user_ids = {int(user_id) for user_id in registration_user_ids}
if not requested_user_ids:
await db.execute(sa_delete(EventRegistration).where(EventRegistration.event_id == event.id))
event.registration_count = 0
return
user_rows = (await db.execute(select(User.id).where(User.id.in_(requested_user_ids)))).scalars().all()
valid_user_ids = {int(user_id) for user_id in user_rows}
await db.execute(sa_delete(EventRegistration).where(EventRegistration.event_id == event.id))
for user_id in valid_user_ids:
db.add(EventRegistration(event_id=event.id, user_id=user_id, status="registered"))
event.registration_count = len(valid_user_ids)
async def _sync_event_photos(
db: AsyncSession,
event: Event,
photos_payload: list[dict],
) -> None:
await db.execute(sa_delete(EventPhoto).where(EventPhoto.event_id == event.id))
if not photos_payload:
return
uploader_ids = {int(item["uploader_id"]) for item in photos_payload}
user_rows = (await db.execute(select(User.id).where(User.id.in_(uploader_ids)))).scalars().all()
valid_uploader_ids = {int(user_id) for user_id in user_rows}
for item in photos_payload:
uploader_id = int(item["uploader_id"])
if uploader_id not in valid_uploader_ids:
continue
image_url = item.get("image_url", "").strip()
if not image_url:
continue
db.add(
EventPhoto(
event_id=event.id,
uploader_id=uploader_id,
image_url=image_url,
caption=(item.get("caption") or None),
spot_id=item.get("spot_id"),
)
)
def _module_design_items() -> list[AdminModuleDesignItem]:
return [
AdminModuleDesignItem(
module_key="users",
module_name="用户与权限",
models=["User"],
api_endpoint_prefixes=["/users", "/auth", "/admin/auth", "/admin/users"],
coverage=AdminModuleCrudCoverage(create=True, read=True, update=True, delete=True),
status="full",
notes="管理端用户 CRUD 与角色/状态管理已覆盖(删除为停用)",
),
AdminModuleDesignItem(
module_key="spots",
module_name="取景地",
models=["Spot", "SpotImage", "Tag", "SpotTag", "Rating", "Favorite", "Correction", "Comment"],
api_endpoint_prefixes=["/spots", "/tags", "/comments", "/ratings", "/favorites", "/corrections", "/admin/spots"],
coverage=AdminModuleCrudCoverage(create=True, read=True, update=True, delete=True),
status="full",
notes="管理端已覆盖取景地完整 CRUD,支持图片与标签关系集中管理",
),
AdminModuleDesignItem(
module_key="events",
module_name="活动",
models=["Event", "EventRegistration", "EventPhoto"],
api_endpoint_prefixes=["/events", "/admin/events"],
coverage=AdminModuleCrudCoverage(create=True, read=True, update=True, delete=True),
status="full",
notes="管理端已覆盖活动完整 CRUD,并支持报名记录与活动相册管理",
),
AdminModuleDesignItem(
module_key="shooting",
module_name="约拍",
models=["ShootingRequest", "ShootingApplication"],
api_endpoint_prefixes=["/shooting", "/admin/shooting"],
coverage=AdminModuleCrudCoverage(create=True, read=True, update=True, delete=True),
status="full",
notes="管理端已覆盖约拍完整 CRUD,并支持申请记录管理",
),
AdminModuleDesignItem(
module_key="promotions",
module_name="推广位",
models=["Promotion"],
api_endpoint_prefixes=["/promotions", "/admin/promotions", "/admin/promotion-link-options"],
coverage=AdminModuleCrudCoverage(create=True, read=True, update=True, delete=True),
status="full",
notes="管理端 CRUD 已覆盖,且关联对象通过筛选器选择(不直接输入 ID)",
),
AdminModuleDesignItem(
module_key="membership",
module_name="会员体系",
models=["MembershipPlan", "UserMembership", "PointLedger"],
api_endpoint_prefixes=["/membership", "/points", "/admin/membership/plans", "/admin/membership/user-memberships", "/admin/points/ledger", "/admin/points/adjust"],
coverage=AdminModuleCrudCoverage(create=True, read=True, update=True, delete=True),
status="partial",
notes="套餐与用户会员 CRUD 已覆盖,积分流水已支持查询与调账",
),
AdminModuleDesignItem(
module_key="notifications",
module_name="通知消息",
models=["Notification"],
api_endpoint_prefixes=["/notifications", "/admin/notifications"],
coverage=AdminModuleCrudCoverage(create=True, read=True, update=True, delete=True),
status="full",
notes="管理端通知 CRUD 已覆盖",
),
AdminModuleDesignItem(
module_key="app_nav_config",
module_name="前端导航配置",
models=["AppNavConfig"],
api_endpoint_prefixes=["/ui-config/nav", "/admin/app-nav-configs"],
coverage=AdminModuleCrudCoverage(create=True, read=True, update=True, delete=True),
status="full",
notes="管理端 CRUD 已覆盖,支持 uni-icons 图标与颜色配置维护",
),
AdminModuleDesignItem(
module_key="audit_support",
module_name="审核与风控辅助",
models=["AuditLog", "Report"],
api_endpoint_prefixes=["/stats", "/admin/audit-logs", "/admin/reports"],
coverage=AdminModuleCrudCoverage(create=True, read=True, update=True, delete=True),
status="full",
notes="举报与审计管理 CRUD 已覆盖",
),
AdminModuleDesignItem(
module_key="system_rules",
module_name="模板与规则中心",
models=["SystemConfig"],
api_endpoint_prefixes=["/admin/system-configs"],
coverage=AdminModuleCrudCoverage(create=True, read=True, update=True, delete=True),
status="full",
notes="消息模板、规则引擎、举报SOP配置已覆盖",
),
]
@router.post("/auth/login", response_model=AdminLoginResponse)
async def admin_login(
payload: AdminLoginRequest,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(User).where(
or_(User.phone == payload.account, User.email == payload.account)
)
)
user = result.scalar_one_or_none()
if not user or not verify_password(payload.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect account or password",
)
_assert_admin_role(user)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is disabled",
)
return AdminLoginResponse(
access_token=create_access_token(user.id),
refresh_token=create_refresh_token(user.id),
user=AdminUserOut.model_validate(user),
)
@router.get("/users", response_model=PageResponse[AdminUserListItem])
async def admin_list_users(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
keyword: str | None = Query(default=None),
role: str | None = Query(default=None),
is_active: bool | None = Query(default=None),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
conditions = []
if keyword:
like = f"%{keyword.strip()}%"
conditions.append(
or_(User.nickname.ilike(like), User.phone.ilike(like), User.email.ilike(like))
)
if role:
conditions.append(User.role == role.strip())
if is_active is not None:
conditions.append(User.is_active == is_active)
total_stmt = select(func.count(User.id))
list_stmt = select(User)
if conditions:
total_stmt = total_stmt.where(*conditions)
list_stmt = list_stmt.where(*conditions)
total = (await db.execute(total_stmt)).scalar() or 0
items = (
await db.execute(
list_stmt
.order_by(User.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
).scalars().all()
return PageResponse(total=total, items=[AdminUserListItem.model_validate(i) for i in items])
@router.get("/users/{user_id}", response_model=AdminUserListItem)
async def admin_get_user(
user_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(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return AdminUserListItem.model_validate(user)
@router.post("/users", response_model=AdminUserListItem, status_code=201)
async def admin_create_user(
payload: AdminUserCreateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
if payload.phone:
exists_phone = await db.execute(select(User.id).where(User.phone == payload.phone))
if exists_phone.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Phone already exists")
if payload.email:
exists_email = await db.execute(select(User.id).where(User.email == payload.email))
if exists_email.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Email already exists")
user = User(
nickname=payload.nickname,
phone=payload.phone,
email=payload.email,
city=payload.city,
identity=payload.identity,
role=payload.role,
is_active=payload.is_active,
password_hash=get_password_hash(payload.password),
)
db.add(user)
await db.commit()
await db.refresh(user)
return AdminUserListItem.model_validate(user)
@router.put("/users/{user_id}", response_model=AdminUserListItem)
async def admin_update_user(
user_id: int,
payload: AdminUserUpdateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
data = payload.model_dump(exclude_unset=True)
if "phone" in data and data["phone"] and data["phone"] != user.phone:
exists_phone = await db.execute(select(User.id).where(User.phone == data["phone"]))
if exists_phone.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Phone already exists")
if "email" in data and data["email"] and data["email"] != user.email:
exists_email = await db.execute(select(User.id).where(User.email == data["email"]))
if exists_email.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Email already exists")
password = data.pop("password", None)
for field, value in data.items():
setattr(user, field, value)
if password:
user.password_hash = get_password_hash(password)
await db.commit()
await db.refresh(user)
return AdminUserListItem.model_validate(user)
@router.delete("/users/{user_id}")
async def admin_delete_user(
user_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
if current_user.id == user_id:
raise HTTPException(status_code=400, detail="Cannot delete current login user")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.is_active = False
user.role = "user"
await db.commit()
return {"code": 0, "message": "disabled"}
@router.get("/auth/me", response_model=AdminUserOut)
async def admin_me(
current_user: User = Depends(get_current_active_user),
):
_assert_admin_role(current_user)
return AdminUserOut.model_validate(current_user)
@router.get("/dashboard", response_model=AdminDashboardStats)
async def admin_dashboard(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
spots_total = (await db.execute(select(func.count(Spot.id)))).scalar() or 0
spots_pending = (
await db.execute(select(func.count(Spot.id)).where(Spot.audit_status == "pending"))
).scalar() or 0
spots_approved = (
await db.execute(select(func.count(Spot.id)).where(Spot.audit_status == "approved"))
).scalar() or 0
spots_rejected = (
await db.execute(select(func.count(Spot.id)).where(Spot.audit_status == "rejected"))
).scalar() or 0
users_total = (await db.execute(select(func.count(User.id)))).scalar() or 0
events_total = (await db.execute(select(func.count(Event.id)))).scalar() or 0
shooting_total = (await db.execute(select(func.count(ShootingRequest.id)))).scalar() or 0
return AdminDashboardStats(
spots_total=spots_total,
spots_pending=spots_pending,
spots_approved=spots_approved,
spots_rejected=spots_rejected,
users_total=users_total,
events_total=events_total,
shooting_total=shooting_total,
)
@router.get("/module-design", response_model=AdminModuleDesignResponse)
async def admin_module_design(
current_user: User = Depends(get_current_active_user),
):
_assert_admin_role(current_user)
items = _module_design_items()
full_covered = sum(1 for i in items if i.status == "full")
partial_covered = sum(1 for i in items if i.status == "partial")
missing_covered = sum(1 for i in items if i.status == "missing")
return AdminModuleDesignResponse(
total_modules=len(items),
full_covered=full_covered,
partial_covered=partial_covered,
missing_covered=missing_covered,
items=items,
)
@router.get("/spots", response_model=PageResponse[AdminSpotListItem])
async def admin_list_spots(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
keyword: str | None = Query(default=None),
city: str | None = Query(default=None),
audit_status: str | None = Query(default=None),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
conditions = []
if keyword:
like = f"%{keyword.strip()}%"
conditions.append(or_(Spot.title.ilike(like), Spot.description.ilike(like)))
if city:
conditions.append(Spot.city == city.strip())
if audit_status:
conditions.append(Spot.audit_status == audit_status.strip())
total_stmt = select(func.count(Spot.id))
list_stmt = select(Spot)
if conditions:
total_stmt = total_stmt.where(*conditions)
list_stmt = list_stmt.where(*conditions)
total = (await db.execute(total_stmt)).scalar() or 0
items = (
await db.execute(
list_stmt
.order_by(Spot.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
).scalars().all()
return PageResponse(total=total, items=[AdminSpotListItem.model_validate(i) for i in items])
@router.get("/spots/export")
async def admin_export_spots(
keyword: str | None = Query(default=None),
city: str | None = Query(default=None),
audit_status: str | None = Query(default=None),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
conditions = []
if keyword:
like = f"%{keyword.strip()}%"
conditions.append(or_(Spot.title.ilike(like), Spot.description.ilike(like)))
if city:
conditions.append(Spot.city == city.strip())
if audit_status:
conditions.append(Spot.audit_status == audit_status.strip())
stmt = select(Spot).order_by(Spot.created_at.desc()).limit(5000)
if conditions:
stmt = stmt.where(*conditions)
items = (await db.execute(stmt)).scalars().all()
rows = [
[
item.id,
item.title,
item.city,
item.audit_status,
item.creator_id,
item.is_free,
float(item.price_min) if item.price_min is not None else "",
float(item.price_max) if item.price_max is not None else "",
item.created_at.isoformat() if item.created_at else "",
]
for item in items
]
content = _build_csv_content(
["id", "title", "city", "audit_status", "creator_id", "is_free", "price_min", "price_max", "created_at"],
rows,
)
return {
"filename": f"spots_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
"content": content,
"count": len(rows),
}
@router.get("/spot-tag-options")
async def admin_spot_tag_options(
keyword: str | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
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]
@router.get("/spots/{spot_id}", response_model=AdminSpotDetailItem)
async def admin_get_spot(
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:
raise HTTPException(status_code=404, detail="Spot not found")
return _spot_to_admin_detail(row[0], lng=row[1], lat=row[2])
@router.post("/spots", response_model=AdminSpotDetailItem, status_code=201)
async def admin_create_spot(
payload: AdminSpotCreateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
user_exists = await db.execute(select(User.id).where(User.id == payload.creator_id))
if user_exists.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Creator user not found")
spot = Spot(
title=payload.title,
city=payload.city,
location=func.ST_SetSRID(func.ST_MakePoint(payload.longitude, payload.latitude), 4326),
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=payload.audit_status,
reject_reason=payload.reject_reason if payload.audit_status == "rejected" else None,
creator_id=payload.creator_id,
)
db.add(spot)
await db.flush()
image_urls = _normalize_image_urls(payload.image_urls)
for idx, image_url in enumerate(image_urls):
db.add(
SpotImage(
spot_id=spot.id,
image_url=image_url,
is_cover=(idx == 0),
sort_order=idx,
)
)
valid_tag_ids: set[int] = set()
if payload.tag_ids:
tag_rows = (
await db.execute(select(Tag.id).where(Tag.id.in_(payload.tag_ids), Tag.is_active.is_(True)))
).scalars().all()
valid_tag_ids = {int(tag_id) for tag_id in tag_rows}
for tag_id in valid_tag_ids:
db.add(SpotTag(spot_id=spot.id, tag_id=tag_id))
await _recount_tag_usage(db, valid_tag_ids)
await db.commit()
await db.refresh(spot)
return _spot_to_admin_detail(spot)
@router.put("/spots/{spot_id}", response_model=AdminSpotDetailItem)
async def admin_update_spot(
spot_id: int,
payload: AdminSpotUpdateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
result = await db.execute(select(Spot).where(Spot.id == spot_id))
spot = result.scalar_one_or_none()
if not spot:
raise HTTPException(status_code=404, detail="Spot not found")
update_data = payload.model_dump(exclude_unset=True)
if "creator_id" in update_data and update_data["creator_id"] is not None:
user_exists = await db.execute(select(User.id).where(User.id == update_data["creator_id"]))
if user_exists.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Creator user not found")
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)
if "is_free" in update_data and update_data["is_free"] is True:
update_data["price_min"] = None
update_data["price_max"] = None
if "audit_status" in update_data and update_data["audit_status"] != "rejected" and "reject_reason" not in update_data:
update_data["reject_reason"] = None
image_urls = update_data.pop("image_urls", None)
tag_ids = update_data.pop("tag_ids", None)
for field, value in update_data.items():
setattr(spot, field, value)
affected_tag_ids: set[int] = set()
if tag_ids is not None:
old_tag_ids = {tag.id for tag in spot.tags}
affected_tag_ids.update(old_tag_ids)
await db.execute(sa_delete(SpotTag).where(SpotTag.spot_id == spot.id))
valid_tag_ids: set[int] = set()
if tag_ids:
tag_rows = (
await db.execute(select(Tag.id).where(Tag.id.in_(tag_ids), Tag.is_active.is_(True)))
).scalars().all()
valid_tag_ids = {int(tag_id) for tag_id in tag_rows}
for tag_id in valid_tag_ids:
db.add(SpotTag(spot_id=spot.id, tag_id=tag_id))
affected_tag_ids.update(valid_tag_ids)
if image_urls is not None:
await db.execute(sa_delete(SpotImage).where(SpotImage.spot_id == spot.id))
normalized = _normalize_image_urls(image_urls)
for idx, image_url in enumerate(normalized):
db.add(
SpotImage(
spot_id=spot.id,
image_url=image_url,
is_cover=(idx == 0),
sort_order=idx,
)
)
await _recount_tag_usage(db, affected_tag_ids)
await db.commit()
await db.refresh(spot)
detail_result = await db.execute(
select(Spot, ST_X(Spot.location).label("lng"), ST_Y(Spot.location).label("lat"))
.where(Spot.id == spot.id)
)
row = detail_result.one()
return _spot_to_admin_detail(row[0], lng=row[1], lat=row[2])
@router.delete("/spots/{spot_id}")
async def admin_delete_spot(
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).where(Spot.id == spot_id))
spot = result.scalar_one_or_none()
if not spot:
raise HTTPException(status_code=404, detail="Spot not found")
affected_tag_ids = {tag.id for tag in spot.tags}
await db.execute(sa_delete(SpotImage).where(SpotImage.spot_id == spot.id))
await db.execute(sa_delete(SpotTag).where(SpotTag.spot_id == spot.id))
await db.delete(spot)
await _recount_tag_usage(db, affected_tag_ids)
await db.commit()
return {"code": 0, "message": "deleted"}
@router.patch("/spots/{spot_id}/audit", response_model=AdminSpotListItem)
async def admin_audit_spot(
spot_id: int,
payload: AdminSpotAuditRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
result = await db.execute(select(Spot).where(Spot.id == spot_id))
spot = result.scalar_one_or_none()
if not spot:
raise HTTPException(status_code=404, detail="Spot not found")
spot.audit_status = payload.audit_status
spot.reject_reason = payload.reject_reason if payload.audit_status == "rejected" else None
await db.commit()
await db.refresh(spot)
return AdminSpotListItem.model_validate(spot)
@router.post("/spots/batch-audit")
async def admin_batch_audit_spots(
payload: AdminBatchAuditRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
ids = list({int(i) for i in payload.ids})
if not ids:
raise HTTPException(status_code=400, detail="ids is required")
rows = (await db.execute(select(Spot).where(Spot.id.in_(ids)))).scalars().all()
for item in rows:
item.audit_status = payload.audit_status
item.reject_reason = payload.reject_reason if payload.audit_status == "rejected" else None
await db.commit()
return {"code": 0, "updated": len(rows)}
@router.get("/events", response_model=PageResponse[AdminEventListItem])
async def admin_list_events(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
keyword: str | None = Query(default=None),
city: str | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
audit_status: str | None = Query(default=None),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
conditions = []
if keyword:
like = f"%{keyword.strip()}%"
conditions.append(or_(Event.title.ilike(like), Event.description.ilike(like)))
if city:
conditions.append(Event.city == city.strip())
if status_filter:
conditions.append(Event.status == status_filter.strip())
if audit_status:
conditions.append(Event.audit_status == audit_status.strip())
total_stmt = select(func.count(Event.id))
list_stmt = select(Event)
if conditions:
total_stmt = total_stmt.where(*conditions)
list_stmt = list_stmt.where(*conditions)
total = (await db.execute(total_stmt)).scalar() or 0
items = (
await db.execute(
list_stmt
.order_by(Event.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
).scalars().all()
return PageResponse(total=total, items=[AdminEventListItem.model_validate(i) for i in items])
@router.get("/events/{event_id}", response_model=AdminEventDetailItem)
async def admin_get_event(
event_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(Event).where(Event.id == event_id))
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
return _event_to_admin_detail(event)
@router.post("/events", response_model=AdminEventDetailItem, status_code=201)
async def admin_create_event(
payload: AdminEventCreateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
creator_exists = await db.execute(select(User.id).where(User.id == payload.creator_id))
if creator_exists.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Creator user not found")
if payload.spot_id is not None:
spot_exists = await db.execute(select(Spot.id).where(Spot.id == payload.spot_id))
if spot_exists.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Spot not found")
event = Event(
creator_id=payload.creator_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=payload.status,
audit_status=payload.audit_status,
reject_reason=payload.reject_reason if payload.audit_status == "rejected" else None,
)
db.add(event)
await db.flush()
await _sync_event_registrations(db, event, payload.registration_user_ids)
await _sync_event_photos(db, event, [item.model_dump() for item in payload.photos])
await db.commit()
result = await db.execute(select(Event).where(Event.id == event.id))
created = result.scalar_one()
return _event_to_admin_detail(created)
@router.put("/events/{event_id}", response_model=AdminEventDetailItem)
async def admin_update_event(
event_id: int,
payload: AdminEventUpdateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
result = await db.execute(select(Event).where(Event.id == event_id))
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
update_data = payload.model_dump(exclude_unset=True)
registration_user_ids = update_data.pop("registration_user_ids", None)
photos_payload = update_data.pop("photos", None)
if "creator_id" in update_data and update_data["creator_id"] is not None:
creator_exists = await db.execute(select(User.id).where(User.id == update_data["creator_id"]))
if creator_exists.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Creator user not found")
if "spot_id" in update_data and update_data["spot_id"] is not None:
spot_exists = await db.execute(select(Spot.id).where(Spot.id == update_data["spot_id"]))
if spot_exists.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Spot not found")
if "audit_status" in update_data and update_data["audit_status"] != "rejected" and "reject_reason" not in update_data:
update_data["reject_reason"] = None
for field, value in update_data.items():
setattr(event, field, value)
if registration_user_ids is not None:
await _sync_event_registrations(db, event, registration_user_ids)
if photos_payload is not None:
await _sync_event_photos(db, event, photos_payload)
await db.commit()
refreshed = (await db.execute(select(Event).where(Event.id == event.id))).scalar_one()
return _event_to_admin_detail(refreshed)
@router.delete("/events/{event_id}")
async def admin_delete_event(
event_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(Event).where(Event.id == event_id))
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
await db.execute(sa_delete(EventRegistration).where(EventRegistration.event_id == event_id))
await db.execute(sa_delete(EventPhoto).where(EventPhoto.event_id == event_id))
await db.delete(event)
await db.commit()
return {"code": 0, "message": "deleted"}
@router.get("/events/{event_id}/registrations", response_model=list[AdminEventRegistrationItem])
async def admin_list_event_registrations(
event_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
event = (await db.execute(select(Event).where(Event.id == event_id))).scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
rows = (
await db.execute(
select(EventRegistration)
.where(EventRegistration.event_id == event_id)
.order_by(EventRegistration.created_at.desc())
)
).scalars().all()
return [_event_registration_item(item) for item in rows]
@router.post("/events/{event_id}/registrations", response_model=AdminEventRegistrationItem, status_code=201)
async def admin_create_event_registration(
event_id: int,
payload: AdminEventRegistrationCreateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
event = (await db.execute(select(Event).where(Event.id == event_id))).scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
user = (await db.execute(select(User).where(User.id == payload.user_id))).scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
exists = (
await db.execute(
select(EventRegistration).where(
EventRegistration.event_id == event_id,
EventRegistration.user_id == payload.user_id,
)
)
).scalar_one_or_none()
if exists:
raise HTTPException(status_code=409, detail="Registration already exists")
if payload.status == "registered" and event.max_participants > 0 and event.registration_count >= event.max_participants:
raise HTTPException(status_code=400, detail="Registration limit reached")
registration = EventRegistration(event_id=event_id, user_id=payload.user_id, status=payload.status)
db.add(registration)
count_stmt = select(func.count(EventRegistration.id)).where(
EventRegistration.event_id == event_id, EventRegistration.status == "registered"
)
event.registration_count = int((await db.execute(count_stmt)).scalar() or 0) + (1 if payload.status == "registered" else 0)
await db.commit()
await db.refresh(registration)
await db.refresh(event)
return _event_registration_item(registration)
@router.put("/events/{event_id}/registrations/{registration_id}", response_model=AdminEventRegistrationItem)
async def admin_update_event_registration(
event_id: int,
registration_id: int,
payload: AdminEventRegistrationUpdateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
registration = (
await db.execute(
select(EventRegistration).where(
EventRegistration.id == registration_id,
EventRegistration.event_id == event_id,
)
)
).scalar_one_or_none()
if not registration:
raise HTTPException(status_code=404, detail="Registration not found")
event = (await db.execute(select(Event).where(Event.id == event_id))).scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
registration.status = payload.status
count_stmt = select(func.count(EventRegistration.id)).where(
EventRegistration.event_id == event_id, EventRegistration.status == "registered"
)
event.registration_count = int((await db.execute(count_stmt)).scalar() or 0)
await db.commit()
await db.refresh(registration)
return _event_registration_item(registration)
@router.delete("/events/{event_id}/registrations/{registration_id}")
async def admin_delete_event_registration(
event_id: int,
registration_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
registration = (
await db.execute(
select(EventRegistration).where(
EventRegistration.id == registration_id,
EventRegistration.event_id == event_id,
)
)
).scalar_one_or_none()
if not registration:
raise HTTPException(status_code=404, detail="Registration not found")
await db.delete(registration)
count_stmt = select(func.count(EventRegistration.id)).where(
EventRegistration.event_id == event_id, EventRegistration.status == "registered"
)
event = (await db.execute(select(Event).where(Event.id == event_id))).scalar_one_or_none()
if event:
event.registration_count = int((await db.execute(count_stmt)).scalar() or 0)
await db.commit()
return {"code": 0, "message": "deleted"}
@router.get("/events/{event_id}/photos", response_model=list[AdminEventPhotoItem])
async def admin_list_event_photos(
event_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
event = (await db.execute(select(Event).where(Event.id == event_id))).scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
rows = (
await db.execute(
select(EventPhoto)
.where(EventPhoto.event_id == event_id)
.order_by(EventPhoto.created_at.desc())
)
).scalars().all()
return [_event_photo_item(item) for item in rows]
@router.post("/events/{event_id}/photos", response_model=AdminEventPhotoItem, status_code=201)
async def admin_create_event_photo(
event_id: int,
payload: AdminEventPhotoCreateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
event = (await db.execute(select(Event).where(Event.id == event_id))).scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
uploader = (await db.execute(select(User).where(User.id == payload.uploader_id))).scalar_one_or_none()
if not uploader:
raise HTTPException(status_code=404, detail="Uploader not found")
if payload.spot_id is not None:
spot_exists = await db.execute(select(Spot.id).where(Spot.id == payload.spot_id))
if spot_exists.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Spot not found")
photo = EventPhoto(
event_id=event_id,
uploader_id=payload.uploader_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 _event_photo_item(photo)
@router.put("/events/{event_id}/photos/{photo_id}", response_model=AdminEventPhotoItem)
async def admin_update_event_photo(
event_id: int,
photo_id: int,
payload: AdminEventPhotoUpdateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
photo = (
await db.execute(
select(EventPhoto).where(EventPhoto.id == photo_id, EventPhoto.event_id == event_id)
)
).scalar_one_or_none()
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
update_data = payload.model_dump(exclude_unset=True)
if "uploader_id" in update_data and update_data["uploader_id"] is not None:
uploader_exists = await db.execute(select(User.id).where(User.id == update_data["uploader_id"]))
if uploader_exists.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Uploader not found")
if "spot_id" in update_data and update_data["spot_id"] is not None:
spot_exists = await db.execute(select(Spot.id).where(Spot.id == update_data["spot_id"]))
if spot_exists.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Spot not found")
for field, value in update_data.items():
setattr(photo, field, value)
await db.commit()
await db.refresh(photo)
return _event_photo_item(photo)
@router.delete("/events/{event_id}/photos/{photo_id}")
async def admin_delete_event_photo(
event_id: int,
photo_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
photo = (
await db.execute(
select(EventPhoto).where(EventPhoto.id == photo_id, EventPhoto.event_id == event_id)
)
).scalar_one_or_none()
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
await db.delete(photo)
await db.commit()
return {"code": 0, "message": "deleted"}
@router.patch("/events/{event_id}/audit", response_model=AdminEventListItem)
async def admin_audit_event(
event_id: int,
payload: AdminAuditRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
result = await db.execute(select(Event).where(Event.id == event_id))
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
event.audit_status = payload.audit_status
event.reject_reason = payload.reject_reason if payload.audit_status == "rejected" else None
await db.commit()
await db.refresh(event)
return AdminEventListItem.model_validate(event)
@router.post("/events/batch-audit")
async def admin_batch_audit_events(
payload: AdminBatchAuditRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
ids = list({int(i) for i in payload.ids})
if not ids:
raise HTTPException(status_code=400, detail="ids is required")
rows = (await db.execute(select(Event).where(Event.id.in_(ids)))).scalars().all()
for item in rows:
item.audit_status = payload.audit_status
item.reject_reason = payload.reject_reason if payload.audit_status == "rejected" else None
await db.commit()
return {"code": 0, "updated": len(rows)}
@router.get("/shooting", response_model=PageResponse[AdminShootingListItem])
async def admin_list_shooting(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
keyword: str | None = Query(default=None),
city: str | None = Query(default=None),
role_needed: str | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
audit_status: str | None = Query(default=None),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
conditions = []
if keyword:
like = f"%{keyword.strip()}%"
conditions.append(or_(ShootingRequest.title.ilike(like), ShootingRequest.description.ilike(like)))
if city:
conditions.append(ShootingRequest.city == city.strip())
if role_needed:
conditions.append(ShootingRequest.role_needed == role_needed.strip())
if status_filter:
conditions.append(ShootingRequest.status == status_filter.strip())
if audit_status:
conditions.append(ShootingRequest.audit_status == audit_status.strip())
total_stmt = select(func.count(ShootingRequest.id))
list_stmt = select(ShootingRequest)
if conditions:
total_stmt = total_stmt.where(*conditions)
list_stmt = list_stmt.where(*conditions)
total = (await db.execute(total_stmt)).scalar() or 0
items = (
await db.execute(
list_stmt
.order_by(ShootingRequest.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
).scalars().all()
return PageResponse(total=total, items=[AdminShootingListItem.model_validate(i) for i in items])
@router.get("/shooting/{request_id}", response_model=AdminShootingDetailItem)
async def admin_get_shooting(
request_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
request = (await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))).scalar_one_or_none()
if not request:
raise HTTPException(status_code=404, detail="Shooting request not found")
return _shooting_to_admin_detail(request)
@router.post("/shooting", response_model=AdminShootingDetailItem, status_code=201)
async def admin_create_shooting(
payload: AdminShootingCreateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
creator_exists = await db.execute(select(User.id).where(User.id == payload.creator_id))
if creator_exists.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Creator user not found")
if payload.spot_id is not None:
spot_exists = await db.execute(select(Spot.id).where(Spot.id == payload.spot_id))
if spot_exists.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Spot not found")
request = ShootingRequest(
creator_id=payload.creator_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=payload.status,
audit_status=payload.audit_status,
reject_reason=payload.reject_reason if payload.audit_status == "rejected" else None,
)
db.add(request)
await db.flush()
await _sync_shooting_applications(db, request, [item.model_dump() for item in payload.applications])
await db.commit()
created = (await db.execute(select(ShootingRequest).where(ShootingRequest.id == request.id))).scalar_one()
return _shooting_to_admin_detail(created)
@router.put("/shooting/{request_id}", response_model=AdminShootingDetailItem)
async def admin_update_shooting(
request_id: int,
payload: AdminShootingUpdateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
request = (await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))).scalar_one_or_none()
if not request:
raise HTTPException(status_code=404, detail="Shooting request not found")
update_data = payload.model_dump(exclude_unset=True)
applications_payload = update_data.pop("applications", None)
if "creator_id" in update_data and update_data["creator_id"] is not None:
creator_exists = await db.execute(select(User.id).where(User.id == update_data["creator_id"]))
if creator_exists.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Creator user not found")
if "spot_id" in update_data and update_data["spot_id"] is not None:
spot_exists = await db.execute(select(Spot.id).where(Spot.id == update_data["spot_id"]))
if spot_exists.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Spot not found")
if "is_free" in update_data and update_data["is_free"] is True:
update_data["budget_min"] = None
update_data["budget_max"] = None
if "audit_status" in update_data and update_data["audit_status"] != "rejected" and "reject_reason" not in update_data:
update_data["reject_reason"] = None
for field, value in update_data.items():
setattr(request, field, value)
if applications_payload is not None:
await _sync_shooting_applications(db, request, applications_payload)
await db.commit()
refreshed = (await db.execute(select(ShootingRequest).where(ShootingRequest.id == request.id))).scalar_one()
return _shooting_to_admin_detail(refreshed)
@router.delete("/shooting/{request_id}")
async def admin_delete_shooting(
request_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
request = (await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))).scalar_one_or_none()
if not request:
raise HTTPException(status_code=404, detail="Shooting request not found")
await db.execute(sa_delete(ShootingApplication).where(ShootingApplication.request_id == request_id))
await db.delete(request)
await db.commit()
return {"code": 0, "message": "deleted"}
@router.get("/shooting/{request_id}/applications", response_model=list[AdminShootingApplicationItem])
async def admin_list_shooting_applications(
request_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
request = (await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))).scalar_one_or_none()
if not request:
raise HTTPException(status_code=404, detail="Shooting request not found")
rows = (
await db.execute(
select(ShootingApplication)
.where(ShootingApplication.request_id == request_id)
.order_by(ShootingApplication.created_at.desc())
)
).scalars().all()
return [_shooting_application_item(item) for item in rows]
@router.post("/shooting/{request_id}/applications", response_model=AdminShootingApplicationItem, status_code=201)
async def admin_create_shooting_application(
request_id: int,
payload: AdminShootingApplicationCreateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
request = (await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))).scalar_one_or_none()
if not request:
raise HTTPException(status_code=404, detail="Shooting request not found")
applicant = (await db.execute(select(User).where(User.id == payload.applicant_id))).scalar_one_or_none()
if not applicant:
raise HTTPException(status_code=404, detail="Applicant user not found")
exists = (
await db.execute(
select(ShootingApplication).where(
ShootingApplication.request_id == request_id,
ShootingApplication.applicant_id == payload.applicant_id,
)
)
).scalar_one_or_none()
if exists:
raise HTTPException(status_code=409, detail="Application already exists")
application = ShootingApplication(
request_id=request_id,
applicant_id=payload.applicant_id,
message=payload.message,
status=payload.status,
)
db.add(application)
await db.commit()
await db.refresh(application)
return _shooting_application_item(application)
@router.put("/shooting/{request_id}/applications/{application_id}", response_model=AdminShootingApplicationItem)
async def admin_update_shooting_application(
request_id: int,
application_id: int,
payload: AdminShootingApplicationUpdateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
application = (
await db.execute(
select(ShootingApplication).where(
ShootingApplication.request_id == request_id,
ShootingApplication.id == application_id,
)
)
).scalar_one_or_none()
if not application:
raise HTTPException(status_code=404, detail="Application not found")
application.message = payload.message
application.status = payload.status
await db.commit()
await db.refresh(application)
return _shooting_application_item(application)
@router.delete("/shooting/{request_id}/applications/{application_id}")
async def admin_delete_shooting_application(
request_id: int,
application_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
application = (
await db.execute(
select(ShootingApplication).where(
ShootingApplication.request_id == request_id,
ShootingApplication.id == application_id,
)
)
).scalar_one_or_none()
if not application:
raise HTTPException(status_code=404, detail="Application not found")
await db.delete(application)
await db.commit()
return {"code": 0, "message": "deleted"}
@router.patch("/shooting/{request_id}/audit", response_model=AdminShootingListItem)
async def admin_audit_shooting(
request_id: int,
payload: AdminAuditRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
result = await db.execute(select(ShootingRequest).where(ShootingRequest.id == request_id))
request = result.scalar_one_or_none()
if not request:
raise HTTPException(status_code=404, detail="Shooting request not found")
request.audit_status = payload.audit_status
request.reject_reason = payload.reject_reason if payload.audit_status == "rejected" else None
await db.commit()
await db.refresh(request)
return AdminShootingListItem.model_validate(request)
@router.post("/shooting/batch-audit")
async def admin_batch_audit_shooting(
payload: AdminBatchAuditRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
ids = list({int(i) for i in payload.ids})
if not ids:
raise HTTPException(status_code=400, detail="ids is required")
rows = (await db.execute(select(ShootingRequest).where(ShootingRequest.id.in_(ids)))).scalars().all()
for item in rows:
item.audit_status = payload.audit_status
item.reject_reason = payload.reject_reason if payload.audit_status == "rejected" else None
await db.commit()
return {"code": 0, "updated": len(rows)}
@router.get("/app-nav-configs", response_model=PageResponse[AppNavConfigOut])
async def admin_list_app_nav_configs(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
keyword: str | None = Query(default=None),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
conditions = []
if keyword:
like = f"%{keyword.strip()}%"
conditions.append(or_(AppNavConfig.label.ilike(like), AppNavConfig.key.ilike(like)))
total_stmt = select(func.count(AppNavConfig.id))
list_stmt = select(AppNavConfig)
if conditions:
total_stmt = total_stmt.where(*conditions)
list_stmt = list_stmt.where(*conditions)
total = (await db.execute(total_stmt)).scalar() or 0
items = (
await db.execute(
list_stmt
.order_by(AppNavConfig.sort_order.asc(), AppNavConfig.id.asc())
.offset((page - 1) * page_size)
.limit(page_size)
)
).scalars().all()
return PageResponse(total=total, items=[AppNavConfigOut.model_validate(i) for i in items])
@router.post("/app-nav-configs", response_model=AppNavConfigOut, status_code=201)
async def admin_create_app_nav_config(
payload: AppNavConfigCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
exists = await db.execute(select(AppNavConfig).where(AppNavConfig.key == payload.key))
if exists.scalar_one_or_none():
raise HTTPException(status_code=409, detail="key already exists")
item = AppNavConfig(**payload.model_dump())
db.add(item)
await db.commit()
await db.refresh(item)
return AppNavConfigOut.model_validate(item)
@router.put("/app-nav-configs/{item_id}", response_model=AppNavConfigOut)
async def admin_update_app_nav_config(
item_id: int,
payload: AppNavConfigUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
result = await db.execute(select(AppNavConfig).where(AppNavConfig.id == item_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="AppNavConfig not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(item, field, value)
await db.commit()
await db.refresh(item)
return AppNavConfigOut.model_validate(item)
@router.delete("/app-nav-configs/{item_id}")
async def admin_delete_app_nav_config(
item_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(AppNavConfig).where(AppNavConfig.id == item_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="AppNavConfig not found")
await db.delete(item)
await db.commit()
return {"code": 0, "message": "deleted"}
@router.get("/promotion-link-options")
async def admin_promotion_link_options(
link_type: str = Query(pattern="^(spot|event|shooting)$"),
keyword: str | None = Query(default=None),
limit: int = Query(default=20, ge=1, le=100),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
if link_type == "spot":
stmt = select(Spot.id, Spot.title).order_by(Spot.created_at.desc()).limit(limit)
if keyword:
like = f"%{keyword.strip()}%"
stmt = select(Spot.id, Spot.title).where(Spot.title.ilike(like)).order_by(Spot.created_at.desc()).limit(limit)
elif link_type == "event":
stmt = select(Event.id, Event.title).order_by(Event.created_at.desc()).limit(limit)
if keyword:
like = f"%{keyword.strip()}%"
stmt = select(Event.id, Event.title).where(Event.title.ilike(like)).order_by(Event.created_at.desc()).limit(limit)
else:
stmt = select(ShootingRequest.id, ShootingRequest.title).order_by(ShootingRequest.created_at.desc()).limit(limit)
if keyword:
like = f"%{keyword.strip()}%"
stmt = select(ShootingRequest.id, ShootingRequest.title).where(ShootingRequest.title.ilike(like)).order_by(ShootingRequest.created_at.desc()).limit(limit)
rows = (await db.execute(stmt)).all()
return [{"id": int(r[0]), "title": str(r[1])} for r in rows]
@router.get("/promotions", response_model=PageResponse[PromotionOut])
async def admin_list_promotions(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
keyword: str | None = Query(default=None),
position: str | None = Query(default=None),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
conditions = []
if keyword:
conditions.append(Promotion.title.ilike(f"%{keyword.strip()}%"))
if position:
conditions.append(Promotion.position == position.strip())
total_stmt = select(func.count(Promotion.id))
list_stmt = select(Promotion)
if conditions:
total_stmt = total_stmt.where(*conditions)
list_stmt = list_stmt.where(*conditions)
total = (await db.execute(total_stmt)).scalar() or 0
items = (
await db.execute(
list_stmt
.order_by(Promotion.sort_order.asc(), Promotion.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
).scalars().all()
return PageResponse(total=total, items=[PromotionOut.model_validate(i) for i in items])
def _normalize_promotion_relation(payload: dict) -> dict:
link_type = payload.get("link_type")
if link_type == "spot":
payload["spot_id"] = payload.get("spot_id") or payload.get("link_id")
payload["event_id"] = None
payload["shooting_id"] = None
payload["link_url"] = None
elif link_type == "event":
payload["event_id"] = payload.get("event_id") or payload.get("link_id")
payload["spot_id"] = None
payload["shooting_id"] = None
payload["link_url"] = None
elif link_type == "shooting":
payload["shooting_id"] = payload.get("shooting_id") or payload.get("link_id")
payload["spot_id"] = None
payload["event_id"] = None
payload["link_url"] = None
elif link_type == "url":
payload["spot_id"] = None
payload["event_id"] = None
payload["shooting_id"] = None
payload["link_id"] = None
return payload
@router.post("/promotions", response_model=PromotionOut, status_code=201)
async def admin_create_promotion(
payload: PromotionCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
data = _normalize_promotion_relation(payload.model_dump())
item = Promotion(**data)
db.add(item)
await db.commit()
await db.refresh(item)
return PromotionOut.model_validate(item)
@router.put("/promotions/{promotion_id}", response_model=PromotionOut)
async def admin_update_promotion(
promotion_id: int,
payload: PromotionUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
result = await db.execute(select(Promotion).where(Promotion.id == promotion_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Promotion not found")
data = _normalize_promotion_relation(payload.model_dump(exclude_unset=True))
for field, value in data.items():
setattr(item, field, value)
await db.commit()
await db.refresh(item)
return PromotionOut.model_validate(item)
@router.delete("/promotions/{promotion_id}")
async def admin_delete_promotion(
promotion_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(Promotion).where(Promotion.id == promotion_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Promotion not found")
await db.delete(item)
await db.commit()
return {"code": 0, "message": "deleted"}
@router.get("/user-options")
async def admin_user_options(
keyword: str | None = Query(default=None),
limit: int = Query(default=20, ge=1, le=100),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
stmt = select(User.id, User.nickname).order_by(User.created_at.desc()).limit(limit)
if keyword:
like = f"%{keyword.strip()}%"
stmt = (
select(User.id, User.nickname)
.where(or_(User.nickname.ilike(like), User.phone.ilike(like), User.email.ilike(like)))
.order_by(User.created_at.desc())
.limit(limit)
)
rows = (await db.execute(stmt)).all()
return [{"id": int(r[0]), "title": str(r[1])} for r in rows]
@router.get("/membership/plans", response_model=PageResponse[AdminMembershipPlanItem])
async def admin_list_membership_plans(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
keyword: str | None = Query(default=None),
is_active: bool | None = Query(default=None),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
conditions = []
if keyword:
like = f"%{keyword.strip()}%"
conditions.append(or_(MembershipPlan.name.ilike(like), MembershipPlan.description.ilike(like)))
if is_active is not None:
conditions.append(MembershipPlan.is_active == is_active)
total_stmt = select(func.count(MembershipPlan.id))
list_stmt = select(MembershipPlan)
if conditions:
total_stmt = total_stmt.where(*conditions)
list_stmt = list_stmt.where(*conditions)
total = (await db.execute(total_stmt)).scalar() or 0
items = (
await db.execute(
list_stmt
.order_by(MembershipPlan.sort_order.asc(), MembershipPlan.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
).scalars().all()
return PageResponse(total=total, items=[AdminMembershipPlanItem.model_validate(i) for i in items])
@router.post("/membership/plans", response_model=AdminMembershipPlanItem, status_code=201)
async def admin_create_membership_plan(
payload: AdminMembershipPlanCreateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
item = MembershipPlan(**payload.model_dump())
db.add(item)
await db.commit()
await db.refresh(item)
return AdminMembershipPlanItem.model_validate(item)
@router.put("/membership/plans/{plan_id}", response_model=AdminMembershipPlanItem)
async def admin_update_membership_plan(
plan_id: int,
payload: AdminMembershipPlanUpdateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
result = await db.execute(select(MembershipPlan).where(MembershipPlan.id == plan_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Membership plan not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(item, field, value)
await db.commit()
await db.refresh(item)
return AdminMembershipPlanItem.model_validate(item)
@router.delete("/membership/plans/{plan_id}")
async def admin_delete_membership_plan(
plan_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(MembershipPlan).where(MembershipPlan.id == plan_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Membership plan not found")
item.is_active = False
await db.commit()
return {"code": 0, "message": "disabled"}
def _to_admin_user_membership_item(um: UserMembership) -> AdminUserMembershipItem:
return AdminUserMembershipItem(
id=um.id,
user_id=um.user_id,
user_nickname=um.user.nickname if um.user else "",
plan_id=um.plan_id,
plan_name=um.plan.name if um.plan else "",
start_date=um.start_date,
end_date=um.end_date,
is_active=um.is_active,
)
@router.get("/membership/user-memberships", response_model=PageResponse[AdminUserMembershipItem])
async def admin_list_user_memberships(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
user_id: int | None = Query(default=None),
plan_id: int | None = Query(default=None),
is_active: bool | None = Query(default=None),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
conditions = []
if user_id:
conditions.append(UserMembership.user_id == user_id)
if plan_id:
conditions.append(UserMembership.plan_id == plan_id)
if is_active is not None:
conditions.append(UserMembership.is_active == is_active)
total_stmt = select(func.count(UserMembership.id))
list_stmt = select(UserMembership)
if conditions:
total_stmt = total_stmt.where(*conditions)
list_stmt = list_stmt.where(*conditions)
total = (await db.execute(total_stmt)).scalar() or 0
items = (
await db.execute(
list_stmt
.order_by(UserMembership.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
).scalars().all()
return PageResponse(total=total, items=[_to_admin_user_membership_item(i) for i in items])
@router.post("/membership/user-memberships", response_model=AdminUserMembershipItem, status_code=201)
async def admin_create_user_membership(
payload: AdminUserMembershipCreateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
user_exists = await db.execute(select(User.id).where(User.id == payload.user_id))
if not user_exists.scalar_one_or_none():
raise HTTPException(status_code=404, detail="User not found")
plan_exists = await db.execute(select(MembershipPlan.id).where(MembershipPlan.id == payload.plan_id))
if not plan_exists.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Membership plan not found")
item = UserMembership(**payload.model_dump())
db.add(item)
await db.commit()
await db.refresh(item)
return _to_admin_user_membership_item(item)
@router.put("/membership/user-memberships/{membership_id}", response_model=AdminUserMembershipItem)
async def admin_update_user_membership(
membership_id: int,
payload: AdminUserMembershipUpdateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
result = await db.execute(select(UserMembership).where(UserMembership.id == membership_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="User membership not found")
data = payload.model_dump(exclude_unset=True)
plan_id = data.get("plan_id")
if plan_id:
plan_exists = await db.execute(select(MembershipPlan.id).where(MembershipPlan.id == plan_id))
if not plan_exists.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Membership plan not found")
for field, value in data.items():
setattr(item, field, value)
await db.commit()
await db.refresh(item)
return _to_admin_user_membership_item(item)
@router.delete("/membership/user-memberships/{membership_id}")
async def admin_delete_user_membership(
membership_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(UserMembership).where(UserMembership.id == membership_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="User membership not found")
item.is_active = False
await db.commit()
return {"code": 0, "message": "disabled"}
def _to_admin_point_item(item: PointLedger, *, rolled_back: bool = False) -> AdminPointLedgerItem:
return AdminPointLedgerItem(
id=item.id,
user_id=item.user_id,
user_nickname=item.user.nickname if item.user else "",
change=item.change,
balance=item.balance,
reason=item.reason,
ref_type=item.ref_type,
ref_id=item.ref_id,
rolled_back=rolled_back,
created_at=item.created_at,
)
def _to_admin_notification_item(item: Notification) -> AdminNotificationItem:
return AdminNotificationItem(
id=item.id,
user_id=item.user_id,
user_nickname=item.user.nickname if item.user else "",
type=item.type,
title=item.title,
content=item.content,
ref_type=item.ref_type,
ref_id=item.ref_id,
is_read=item.is_read,
created_at=item.created_at,
)
def _to_admin_audit_log_item(item: AuditLog) -> AdminAuditLogItem:
return AdminAuditLogItem(
id=item.id,
operator_id=item.operator_id,
operator_nickname=item.operator.nickname if item.operator else "",
action=item.action,
target_type=item.target_type,
target_id=item.target_id,
detail=item.detail,
created_at=item.created_at,
)
def _to_admin_report_item(item: Report) -> AdminReportItem:
return AdminReportItem(
id=item.id,
reporter_id=item.reporter_id,
reporter_nickname=item.reporter.nickname if item.reporter else "",
target_type=item.target_type,
target_id=item.target_id,
reason=item.reason,
status=item.status,
handler_id=item.handler_id,
handler_nickname=item.handler.nickname if item.handler else None,
conclusion=item.conclusion,
created_at=item.created_at,
resolved_at=item.resolved_at,
)
@router.get("/points/ledger", response_model=PageResponse[AdminPointLedgerItem])
async def admin_list_point_ledger(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
user_id: int | None = Query(default=None),
reason: str | None = Query(default=None),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
conditions = []
if user_id:
conditions.append(PointLedger.user_id == user_id)
if reason:
conditions.append(PointLedger.reason.ilike(f"%{reason.strip()}%"))
total_stmt = select(func.count(PointLedger.id))
list_stmt = select(PointLedger)
if conditions:
total_stmt = total_stmt.where(*conditions)
list_stmt = list_stmt.where(*conditions)
total = (await db.execute(total_stmt)).scalar() or 0
items = (
await db.execute(
list_stmt
.order_by(PointLedger.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
).scalars().all()
item_ids = [item.id for item in items]
rolled_back_ids: set[int] = set()
if item_ids:
rolled_back_rows = (
await db.execute(
select(PointLedger.ref_id).where(
PointLedger.ref_type == "points_rollback",
PointLedger.ref_id.in_(item_ids),
)
)
).scalars().all()
rolled_back_ids = {int(i) for i in rolled_back_rows if i is not None}
return PageResponse(
total=total,
items=[_to_admin_point_item(i, rolled_back=i.id in rolled_back_ids) for i in items],
)
@router.post("/points/adjust", response_model=AdminPointLedgerItem, status_code=201)
async def admin_adjust_points(
payload: AdminPointAdjustRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
user_result = await db.execute(select(User).where(User.id == payload.user_id))
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
last_result = await db.execute(
select(PointLedger).where(PointLedger.user_id == payload.user_id).order_by(PointLedger.id.desc()).limit(1)
)
last = last_result.scalar_one_or_none()
last_balance = last.balance if last else 0
new_balance = last_balance + payload.change
item = PointLedger(
user_id=payload.user_id,
change=payload.change,
balance=new_balance,
reason=payload.reason,
ref_type=payload.ref_type,
ref_id=payload.ref_id,
)
db.add(item)
await db.commit()
await db.refresh(item)
return _to_admin_point_item(item)
@router.post("/points/ledger/{ledger_id}/rollback", response_model=AdminPointLedgerItem, status_code=201)
async def admin_rollback_point_ledger(
ledger_id: int,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
target_result = await db.execute(select(PointLedger).where(PointLedger.id == ledger_id))
target = target_result.scalar_one_or_none()
if not target:
raise HTTPException(status_code=404, detail="Point ledger not found")
if target.ref_type == "points_rollback":
raise HTTPException(status_code=400, detail="Rollback ledger cannot be rolled back")
exists_rollback = await db.execute(
select(PointLedger.id).where(
PointLedger.ref_type == "points_rollback",
PointLedger.ref_id == ledger_id,
)
)
if exists_rollback.scalar_one_or_none():
raise HTTPException(status_code=409, detail="This ledger has already been rolled back")
last_result = await db.execute(
select(PointLedger).where(PointLedger.user_id == target.user_id).order_by(PointLedger.id.desc()).limit(1)
)
last = last_result.scalar_one_or_none()
last_balance = last.balance if last else 0
rollback_change = -target.change
new_balance = last_balance + rollback_change
rollback_item = PointLedger(
user_id=target.user_id,
change=rollback_change,
balance=new_balance,
reason=f"回滚流水#{ledger_id}{target.reason}",
ref_type="points_rollback",
ref_id=ledger_id,
)
db.add(rollback_item)
await db.commit()
await db.refresh(rollback_item)
return _to_admin_point_item(rollback_item, rolled_back=False)
@router.get("/notifications", response_model=PageResponse[AdminNotificationItem])
async def admin_list_notifications(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
user_id: int | None = Query(default=None),
type: str | None = Query(default=None),
is_read: bool | None = Query(default=None),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
conditions = []
if user_id:
conditions.append(Notification.user_id == user_id)
if type:
conditions.append(Notification.type == type.strip())
if is_read is not None:
conditions.append(Notification.is_read == is_read)
total_stmt = select(func.count(Notification.id))
list_stmt = select(Notification)
if conditions:
total_stmt = total_stmt.where(*conditions)
list_stmt = list_stmt.where(*conditions)
total = (await db.execute(total_stmt)).scalar() or 0
items = (
await db.execute(
list_stmt
.order_by(Notification.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
).scalars().all()
return PageResponse(total=total, items=[_to_admin_notification_item(i) for i in items])
@router.post("/notifications", response_model=AdminNotificationItem, status_code=201)
async def admin_create_notification(
payload: AdminNotificationCreateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
user_exists = await db.execute(select(User.id).where(User.id == payload.user_id))
if not user_exists.scalar_one_or_none():
raise HTTPException(status_code=404, detail="User not found")
item = Notification(**payload.model_dump(), is_read=False)
db.add(item)
db.add(
AuditLog(
operator_id=current_user.id,
action="admin_notification_create",
target_type="notification",
target_id=None,
detail=f"user_id={payload.user_id}, title={payload.title}",
)
)
await db.commit()
await db.refresh(item)
return _to_admin_notification_item(item)
@router.put("/notifications/{notification_id}", response_model=AdminNotificationItem)
async def admin_update_notification(
notification_id: int,
payload: AdminNotificationUpdateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
result = await db.execute(select(Notification).where(Notification.id == notification_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Notification not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(item, field, value)
db.add(
AuditLog(
operator_id=current_user.id,
action="admin_notification_update",
target_type="notification",
target_id=item.id,
detail="updated",
)
)
await db.commit()
await db.refresh(item)
return _to_admin_notification_item(item)
@router.delete("/notifications/{notification_id}")
async def admin_delete_notification(
notification_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(Notification).where(Notification.id == notification_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Notification not found")
db.add(
AuditLog(
operator_id=current_user.id,
action="admin_notification_delete",
target_type="notification",
target_id=item.id,
detail="deleted",
)
)
await db.delete(item)
await db.commit()
return {"code": 0, "message": "deleted"}
@router.get("/audit-logs", response_model=PageResponse[AdminAuditLogItem])
async def admin_list_audit_logs(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
operator_id: int | None = Query(default=None),
action: str | None = Query(default=None),
target_type: str | None = Query(default=None),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
conditions = []
if operator_id:
conditions.append(AuditLog.operator_id == operator_id)
if action:
conditions.append(AuditLog.action == action.strip())
if target_type:
conditions.append(AuditLog.target_type == target_type.strip())
total_stmt = select(func.count(AuditLog.id))
list_stmt = select(AuditLog)
if conditions:
total_stmt = total_stmt.where(*conditions)
list_stmt = list_stmt.where(*conditions)
total = (await db.execute(total_stmt)).scalar() or 0
items = (
await db.execute(
list_stmt
.order_by(AuditLog.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
).scalars().all()
return PageResponse(total=total, items=[_to_admin_audit_log_item(i) for i in items])
@router.post("/audit-logs", response_model=AdminAuditLogItem, status_code=201)
async def admin_create_audit_log(
payload: AdminAuditLogCreateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
item = AuditLog(
operator_id=current_user.id,
action=payload.action,
target_type=payload.target_type,
target_id=payload.target_id,
detail=payload.detail,
)
db.add(item)
await db.commit()
await db.refresh(item)
return _to_admin_audit_log_item(item)
@router.get("/reports", response_model=PageResponse[AdminReportItem])
async def admin_list_reports(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
status_filter: str | None = Query(default=None, alias="status"),
target_type: str | None = Query(default=None),
reporter_id: int | None = Query(default=None),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
conditions = []
if status_filter:
conditions.append(Report.status == status_filter.strip())
if target_type:
conditions.append(Report.target_type == target_type.strip())
if reporter_id:
conditions.append(Report.reporter_id == reporter_id)
total_stmt = select(func.count(Report.id))
list_stmt = select(Report)
if conditions:
total_stmt = total_stmt.where(*conditions)
list_stmt = list_stmt.where(*conditions)
total = (await db.execute(total_stmt)).scalar() or 0
items = (
await db.execute(
list_stmt
.order_by(Report.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
).scalars().all()
return PageResponse(total=total, items=[_to_admin_report_item(i) for i in items])
@router.put("/reports/{report_id}", response_model=AdminReportItem)
async def admin_update_report(
report_id: int,
payload: AdminReportUpdateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
result = await db.execute(select(Report).where(Report.id == report_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Report not found")
data = payload.model_dump(exclude_unset=True)
if "status" in data and data["status"] in ("resolved", "rejected"):
data["resolved_at"] = datetime.now(timezone.utc)
if "status" in data and data["status"] == "processing" and "handler_id" not in data:
data["handler_id"] = current_user.id
for field, value in data.items():
setattr(item, field, value)
db.add(
AuditLog(
operator_id=current_user.id,
action="admin_report_update",
target_type="report",
target_id=item.id,
detail=f"status={item.status}",
)
)
await db.commit()
await db.refresh(item)
return _to_admin_report_item(item)
@router.delete("/reports/{report_id}")
async def admin_delete_report(
report_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(Report).where(Report.id == report_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Report not found")
db.add(
AuditLog(
operator_id=current_user.id,
action="admin_report_delete",
target_type="report",
target_id=item.id,
detail="deleted",
)
)
await db.delete(item)
await db.commit()
return {"code": 0, "message": "deleted"}
@router.get("/system-configs", response_model=PageResponse[AdminSystemConfigItem])
async def admin_list_system_configs(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
category: str | None = Query(default=None),
keyword: str | None = Query(default=None),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
conditions = []
if category:
conditions.append(SystemConfig.category == category.strip())
if keyword:
like = f"%{keyword.strip()}%"
conditions.append(or_(SystemConfig.title.ilike(like), SystemConfig.config_key.ilike(like)))
total_stmt = select(func.count(SystemConfig.id))
list_stmt = select(SystemConfig)
if conditions:
total_stmt = total_stmt.where(*conditions)
list_stmt = list_stmt.where(*conditions)
total = (await db.execute(total_stmt)).scalar() or 0
items = (
await db.execute(
list_stmt
.order_by(SystemConfig.sort_order.asc(), SystemConfig.id.asc())
.offset((page - 1) * page_size)
.limit(page_size)
)
).scalars().all()
return PageResponse(total=total, items=[AdminSystemConfigItem.model_validate(i) for i in items])
@router.post("/system-configs", response_model=AdminSystemConfigItem, status_code=201)
async def admin_create_system_config(
payload: AdminSystemConfigCreateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
exists = await db.execute(select(SystemConfig.id).where(SystemConfig.config_key == payload.config_key))
if exists.scalar_one_or_none():
raise HTTPException(status_code=409, detail="config_key already exists")
item = SystemConfig(**payload.model_dump(), updated_by=current_user.id)
db.add(item)
await db.commit()
await db.refresh(item)
return AdminSystemConfigItem.model_validate(item)
@router.put("/system-configs/{config_id}", response_model=AdminSystemConfigItem)
async def admin_update_system_config(
config_id: int,
payload: AdminSystemConfigUpdateRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
_assert_admin_role(current_user)
result = await db.execute(select(SystemConfig).where(SystemConfig.id == config_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="System config not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(item, field, value)
item.updated_by = current_user.id
db.add(
AuditLog(
operator_id=current_user.id,
action="admin_system_config_update",
target_type="system_config",
target_id=item.id,
detail=f"key={item.config_key}",
)
)
await db.commit()
await db.refresh(item)
return AdminSystemConfigItem.model_validate(item)
@router.delete("/system-configs/{config_id}")
async def admin_delete_system_config(
config_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(SystemConfig).where(SystemConfig.id == config_id))
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="System config not found")
await db.delete(item)
await db.commit()
return {"code": 0, "message": "deleted"}