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"}