first commit

This commit is contained in:
2026-07-18 20:32:51 +08:00
commit 77bf076ad0
34 changed files with 4068 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""API routers."""
+196
View File
@@ -0,0 +1,196 @@
import math
import uuid
from datetime import datetime, timezone
from typing import Literal
from zoneinfo import ZoneInfo
from fastapi import APIRouter, HTTPException, Query, status
from sqlalchemy import case, func, select, update
from sqlalchemy.orm import joinedload, selectinload
from app.deps import AdminUser, DbSession
from app.models import Cloud, Profile, User
from app.schemas import (
AdminCloudStatusIn,
AdminCloudVisibilityIn,
AdminStatsOut,
AdminUserOut,
AdminUserUpdateIn,
BatchIdsIn,
BatchUpdateOut,
CloudOut,
DeleteResultOut,
PageOut,
)
from app.serializers import cloud_out, profile_out, user_out
from app.services.storage import delete_files
router = APIRouter(prefix="/admin", tags=["管理后台"])
@router.get("/stats", response_model=AdminStatsOut)
async def get_stats(_: AdminUser, db: DbSession) -> AdminStatsOut:
local_tz = ZoneInfo("Asia/Shanghai")
local_now = datetime.now(local_tz)
today_start = local_now.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc)
users = await db.scalar(select(func.count()).select_from(User)) or 0
row = (
await db.execute(
select(
func.count(Cloud.id).label("images"),
func.sum(case((Cloud.created_at >= today_start, 1), else_=0)).label("today"),
func.sum(case((Cloud.status == "pending", 1), else_=0)).label("pending"),
func.sum(case((Cloud.status == "approved", 1), else_=0)).label("approved"),
func.sum(case((Cloud.status == "rejected", 1), else_=0)).label("rejected"),
func.sum(case((Cloud.is_hidden.is_(True), 1), else_=0)).label("hidden"),
)
)
).one()
return AdminStatsOut(
users=users,
images=row.images or 0,
today_uploads=row.today or 0,
pending=row.pending or 0,
approved=row.approved or 0,
rejected=row.rejected or 0,
hidden=row.hidden or 0,
)
@router.get("/users", response_model=PageOut[AdminUserOut])
async def list_users(
_: AdminUser,
db: DbSession,
page: int = Query(1, ge=1),
page_size: int = Query(100, ge=1, le=100),
) -> PageOut[AdminUserOut]:
total = await db.scalar(select(func.count()).select_from(User)) or 0
result = await db.execute(
select(User)
.options(selectinload(User.profile))
.order_by(User.created_at.desc(), User.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
items = [AdminUserOut(user=user_out(user), profile=profile_out(user.profile)) for user in result.scalars()]
return PageOut(
items=items,
page=page,
page_size=page_size,
total=total,
total_pages=max(1, math.ceil(total / page_size)),
)
@router.patch("/users/{user_id}", response_model=AdminUserOut)
async def update_user(
user_id: uuid.UUID,
payload: AdminUserUpdateIn,
admin: AdminUser,
db: DbSession,
) -> AdminUserOut:
result = await db.execute(
select(User).options(selectinload(User.profile)).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
if user.id == admin.id and (
payload.is_disabled is True or (payload.role is not None and payload.role != "admin")
):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能禁用自己或移除自己的管理员权限")
if payload.role is not None:
user.profile.role = payload.role
if payload.is_disabled is not None:
user.profile.is_disabled = payload.is_disabled
await db.commit()
return AdminUserOut(user=user_out(user), profile=profile_out(user.profile))
@router.get("/clouds", response_model=PageOut[CloudOut])
async def list_admin_clouds(
_: AdminUser,
db: DbSession,
page: int = Query(1, ge=1),
page_size: int = Query(100, ge=1, le=120),
cloud_status: Literal["pending", "approved", "rejected"] | None = Query(None, alias="status"),
is_hidden: bool | None = None,
) -> PageOut[CloudOut]:
filters = []
if cloud_status:
filters.append(Cloud.status == cloud_status)
if is_hidden is not None:
filters.append(Cloud.is_hidden == is_hidden)
total = await db.scalar(select(func.count()).select_from(Cloud).where(*filters)) or 0
result = await db.execute(
select(Cloud)
.options(joinedload(Cloud.user).joinedload(User.profile), joinedload(Cloud.cloud_type))
.where(*filters)
.order_by(Cloud.created_at.desc(), Cloud.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
return PageOut(
items=[cloud_out(item) for item in result.scalars().unique().all()],
page=page,
page_size=page_size,
total=total,
total_pages=max(1, math.ceil(total / page_size)),
)
async def _require_all_cloud_ids(db: DbSession, ids: list[uuid.UUID]) -> None:
count = await db.scalar(select(func.count()).select_from(Cloud).where(Cloud.id.in_(ids))) or 0
if count != len(ids):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部分图片不存在")
@router.patch("/clouds/status", response_model=BatchUpdateOut)
async def update_cloud_status(
payload: AdminCloudStatusIn,
_: AdminUser,
db: DbSession,
) -> BatchUpdateOut:
await _require_all_cloud_ids(db, payload.ids)
await db.execute(
update(Cloud)
.where(Cloud.id.in_(payload.ids))
.values(status=payload.status, updated_at=func.now())
)
await db.commit()
return BatchUpdateOut(updated=len(payload.ids))
@router.patch("/clouds/visibility", response_model=BatchUpdateOut)
async def update_cloud_visibility(
payload: AdminCloudVisibilityIn,
_: AdminUser,
db: DbSession,
) -> BatchUpdateOut:
await _require_all_cloud_ids(db, payload.ids)
await db.execute(
update(Cloud)
.where(Cloud.id.in_(payload.ids))
.values(is_hidden=payload.is_hidden, updated_at=func.now())
)
await db.commit()
return BatchUpdateOut(updated=len(payload.ids))
@router.post("/clouds/batch-delete", response_model=DeleteResultOut)
async def delete_admin_clouds(
payload: BatchIdsIn,
_: AdminUser,
db: DbSession,
) -> DeleteResultOut:
result = await db.execute(select(Cloud).where(Cloud.id.in_(payload.ids)))
clouds = result.scalars().all()
if len(clouds) != len(payload.ids):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部分图片不存在")
paths = [path for cloud in clouds for path in (cloud.image_path, cloud.thumbnail_path)]
for cloud in clouds:
await db.delete(cloud)
await db.commit()
await delete_files(paths)
return DeleteResultOut(deleted=len(clouds))
+343
View File
@@ -0,0 +1,343 @@
import logging
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException, Request, Response, status
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
from app.config import settings
from app.deps import CurrentUser, DbSession
from app.models import EmailToken, Profile, RefreshSession, User
from app.schemas import (
AuthOut,
ChangePasswordIn,
ForgotPasswordIn,
LoginIn,
MeOut,
MessageOut,
RegisterIn,
ResetPasswordIn,
TokenIn,
)
from app.security import (
DUMMY_PASSWORD_HASH,
create_access_token,
create_random_token,
hash_password,
hash_token,
refresh_expires_at,
utc_now,
verify_password,
)
from app.serializers import profile_out, user_out
from app.services.email import send_confirmation_email, send_password_reset_email
router = APIRouter(prefix="/auth", tags=["认证"])
logger = logging.getLogger(__name__)
def _aware_utc(value: datetime) -> datetime:
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
def _client_metadata(request: Request) -> tuple[str | None, str | None]:
user_agent = request.headers.get("user-agent")
ip_address = request.client.host if request.client else None
return user_agent, ip_address
def _set_refresh_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=settings.refresh_cookie_name,
value=token,
max_age=settings.refresh_token_expire_days * 24 * 60 * 60,
httponly=True,
secure=settings.cookie_secure,
samesite=settings.cookie_samesite,
domain=settings.cookie_domain,
path=f"{settings.api_v1_prefix}/auth",
)
def _clear_refresh_cookie(response: Response) -> None:
response.delete_cookie(
key=settings.refresh_cookie_name,
domain=settings.cookie_domain,
path=f"{settings.api_v1_prefix}/auth",
secure=settings.cookie_secure,
httponly=True,
samesite=settings.cookie_samesite,
)
def _auth_response(user: User, session: RefreshSession) -> AuthOut:
return AuthOut(
access_token=create_access_token(user.id, session.id),
expires_in=settings.access_token_expire_minutes * 60,
user=user_out(user),
profile=profile_out(user.profile),
)
def _new_session(user: User, request: Request) -> tuple[RefreshSession, str]:
token = create_random_token()
user_agent, ip_address = _client_metadata(request)
session = RefreshSession(
user_id=user.id,
token_hash=hash_token(token),
expires_at=refresh_expires_at(),
user_agent=user_agent,
ip_address=ip_address,
)
return session, token
async def _create_email_token(db: DbSession, user: User, purpose: str, expires_delta: timedelta) -> str:
now = utc_now()
await db.execute(
update(EmailToken)
.where(
EmailToken.user_id == user.id,
EmailToken.purpose == purpose,
EmailToken.used_at.is_(None),
)
.values(used_at=now)
)
token = create_random_token()
db.add(
EmailToken(
user_id=user.id,
purpose=purpose,
token_hash=hash_token(token),
expires_at=now + expires_delta,
)
)
return token
@router.post("/register", response_model=MessageOut, status_code=status.HTTP_201_CREATED)
async def register(payload: RegisterIn, db: DbSession) -> MessageOut:
email = str(payload.email).strip().lower()
user = User(email=email, password_hash=hash_password(payload.password))
user.profile = Profile(username=payload.username)
db.add(user)
try:
await db.flush()
token = await _create_email_token(
db,
user,
"confirm_email",
timedelta(hours=settings.email_confirmation_expire_hours),
)
await db.commit()
except IntegrityError as exc:
await db.rollback()
message = str(exc.orig).lower()
if "email" in message:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该邮箱已被注册") from exc
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="这个昵称已经被使用") from exc
try:
await send_confirmation_email(user.email, token)
except Exception:
logger.exception("注册成功,但确认邮件发送失败:%s", user.email)
return MessageOut(message="注册成功,请查收确认邮件")
@router.post("/resend-confirmation", response_model=MessageOut, status_code=status.HTTP_202_ACCEPTED)
async def resend_confirmation(payload: ForgotPasswordIn, db: DbSession) -> MessageOut:
email = str(payload.email).strip().lower()
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if user and user.email_verified_at is None:
token = await _create_email_token(
db,
user,
"confirm_email",
timedelta(hours=settings.email_confirmation_expire_hours),
)
await db.commit()
try:
await send_confirmation_email(user.email, token)
except Exception:
logger.exception("确认邮件重发失败:%s", user.email)
return MessageOut(message="如果账号存在且尚未确认,确认邮件将会发送")
@router.post("/confirm-email", response_model=MessageOut)
async def confirm_email(payload: TokenIn, db: DbSession) -> MessageOut:
now = utc_now()
result = await db.execute(
select(EmailToken)
.where(
EmailToken.token_hash == hash_token(payload.token),
EmailToken.purpose == "confirm_email",
EmailToken.used_at.is_(None),
EmailToken.expires_at > now,
)
.with_for_update()
)
email_token = result.scalar_one_or_none()
if not email_token:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="确认链接无效或已过期")
user = await db.get(User, email_token.user_id)
if not user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="账号不存在")
user.email_verified_at = user.email_verified_at or now
email_token.used_at = now
await db.commit()
return MessageOut(message="邮箱确认成功")
@router.post("/login", response_model=AuthOut)
async def login(payload: LoginIn, request: Request, response: Response, db: DbSession) -> AuthOut:
email = str(payload.email).strip().lower()
result = await db.execute(
select(User).options(selectinload(User.profile)).where(User.email == email)
)
user = result.scalar_one_or_none()
encoded_hash = user.password_hash if user else DUMMY_PASSWORD_HASH
password_valid = verify_password(payload.password, encoded_hash)
if not user or not password_valid:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码错误")
if user.email_verified_at is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="邮箱尚未确认")
if user.profile.is_disabled:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用")
session, refresh_token = _new_session(user, request)
db.add(session)
await db.commit()
_set_refresh_cookie(response, refresh_token)
return _auth_response(user, session)
@router.post("/refresh", response_model=AuthOut)
async def refresh(request: Request, response: Response, db: DbSession) -> AuthOut:
raw_token = request.cookies.get(settings.refresh_cookie_name)
if not raw_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="缺少刷新令牌")
now = utc_now()
result = await db.execute(
select(RefreshSession)
.options(selectinload(RefreshSession.user).selectinload(User.profile))
.where(RefreshSession.token_hash == hash_token(raw_token))
.with_for_update()
)
old_session = result.scalar_one_or_none()
if (
not old_session
or old_session.revoked_at is not None
or _aware_utc(old_session.expires_at) <= now
):
_clear_refresh_cookie(response)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="刷新令牌无效或已过期")
user = old_session.user
if user.profile.is_disabled:
old_session.revoked_at = now
await db.commit()
_clear_refresh_cookie(response)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用")
new_session, new_token = _new_session(user, request)
db.add(new_session)
await db.flush()
old_session.revoked_at = now
old_session.replaced_by_id = new_session.id
await db.commit()
_set_refresh_cookie(response, new_token)
return _auth_response(user, new_session)
@router.post("/logout", response_model=MessageOut)
async def logout(request: Request, response: Response, db: DbSession) -> MessageOut:
raw_token = request.cookies.get(settings.refresh_cookie_name)
if raw_token:
result = await db.execute(
select(RefreshSession).where(RefreshSession.token_hash == hash_token(raw_token))
)
session = result.scalar_one_or_none()
if session and session.revoked_at is None:
session.revoked_at = utc_now()
await db.commit()
_clear_refresh_cookie(response)
return MessageOut(message="已退出登录")
@router.get("/me", response_model=MeOut)
async def me(user: CurrentUser) -> MeOut:
return MeOut(user=user_out(user), profile=profile_out(user.profile))
@router.post("/forgot-password", response_model=MessageOut, status_code=status.HTTP_202_ACCEPTED)
async def forgot_password(payload: ForgotPasswordIn, db: DbSession) -> MessageOut:
email = str(payload.email).strip().lower()
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if user and user.email_verified_at is not None:
token = await _create_email_token(
db,
user,
"reset_password",
timedelta(minutes=settings.password_reset_expire_minutes),
)
await db.commit()
try:
await send_password_reset_email(user.email, token)
except Exception:
logger.exception("密码重置邮件发送失败:%s", user.email)
return MessageOut(message="如果该邮箱已经注册,密码重置邮件将会发送")
@router.post("/reset-password", response_model=MessageOut)
async def reset_password(payload: ResetPasswordIn, db: DbSession) -> MessageOut:
now = utc_now()
result = await db.execute(
select(EmailToken)
.where(
EmailToken.token_hash == hash_token(payload.token),
EmailToken.purpose == "reset_password",
EmailToken.used_at.is_(None),
EmailToken.expires_at > now,
)
.with_for_update()
)
email_token = result.scalar_one_or_none()
if not email_token:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重置链接无效或已过期")
user = await db.get(User, email_token.user_id)
if not user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="账号不存在")
user.password_hash = hash_password(payload.password)
email_token.used_at = now
await db.execute(
update(RefreshSession)
.where(RefreshSession.user_id == user.id, RefreshSession.revoked_at.is_(None))
.values(revoked_at=now)
)
await db.commit()
return MessageOut(message="密码已重置,请重新登录")
@router.patch("/password", response_model=AuthOut)
async def change_password(
payload: ChangePasswordIn,
request: Request,
response: Response,
user: CurrentUser,
db: DbSession,
) -> AuthOut:
now = utc_now()
user.password_hash = hash_password(payload.password)
await db.execute(
update(RefreshSession)
.where(RefreshSession.user_id == user.id, RefreshSession.revoked_at.is_(None))
.values(revoked_at=now)
)
new_session, refresh_token = _new_session(user, request)
db.add(new_session)
await db.commit()
_set_refresh_cookie(response, refresh_token)
return _auth_response(user, new_session)
+59
View File
@@ -0,0 +1,59 @@
import math
from fastapi import APIRouter, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.orm import joinedload
from app.deps import DbSession
from app.models import Cloud, CloudType, User
from app.schemas import CloudOut, CloudTypeOut, PageOut
from app.serializers import cloud_out, cloud_type_out
router = APIRouter(prefix="/cloud-types", tags=["云类型"])
@router.get("", response_model=list[CloudTypeOut])
async def list_cloud_types(db: DbSession) -> list[CloudTypeOut]:
result = await db.execute(select(CloudType).order_by(CloudType.id))
return [cloud_type_out(item) for item in result.scalars().all()]
@router.get("/{cloud_type_id}", response_model=CloudTypeOut)
async def get_cloud_type(cloud_type_id: int, db: DbSession) -> CloudTypeOut:
cloud_type = await db.get(CloudType, cloud_type_id)
if not cloud_type:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="云类型不存在")
return cloud_type_out(cloud_type)
@router.get("/{cloud_type_id}/clouds", response_model=PageOut[CloudOut])
async def get_cloud_type_clouds(
cloud_type_id: int,
db: DbSession,
page: int = Query(1, ge=1),
page_size: int = Query(24, ge=1, le=100),
) -> PageOut[CloudOut]:
if not await db.get(CloudType, cloud_type_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="云类型不存在")
filters = [
Cloud.cloud_type_id == cloud_type_id,
Cloud.status == "approved",
Cloud.is_hidden.is_(False),
]
total = await db.scalar(select(func.count()).select_from(Cloud).where(*filters)) or 0
result = await db.execute(
select(Cloud)
.options(joinedload(Cloud.user).joinedload(User.profile), joinedload(Cloud.cloud_type))
.where(*filters)
.order_by(Cloud.captured_at.desc().nulls_last(), Cloud.created_at.desc(), Cloud.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
return PageOut(
items=[cloud_out(item, include_private=False) for item in result.scalars().unique().all()],
page=page,
page_size=page_size,
total=total,
total_pages=max(1, math.ceil(total / page_size)),
)
+326
View File
@@ -0,0 +1,326 @@
import math
import uuid
from datetime import datetime, timezone
from decimal import Decimal, ROUND_HALF_UP
from typing import Annotated, Literal
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, status
from sqlalchemy import func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.orm import joinedload
from app.deps import CurrentUser, DbSession, OptionalUser
from app.models import Cloud, CloudType, Profile, User, UserCollection
from app.schemas import (
BadgeOut,
BatchIdsIn,
CloudCreateOut,
CloudOut,
CloudUpdateIn,
DeleteResultOut,
PageOut,
)
from app.serializers import cloud_out
from app.services.storage import delete_files, save_cloud_image
router = APIRouter(prefix="/clouds", tags=["云图"])
def _cloud_options():
return (
joinedload(Cloud.user).joinedload(User.profile),
joinedload(Cloud.cloud_type),
)
def _normalize_text(value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None
def _blur_coordinate(value: float | None) -> Decimal | None:
if value is None:
return None
return Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def _normalize_datetime(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
def _normalize_required_datetime(value: datetime) -> datetime:
return _normalize_datetime(value) or value.replace(tzinfo=timezone.utc)
async def _require_cloud_type(db: DbSession, cloud_type_id: int) -> CloudType:
cloud_type = await db.get(CloudType, cloud_type_id)
if not cloud_type:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="云类型不存在")
return cloud_type
async def _get_cloud(db: DbSession, cloud_id: uuid.UUID) -> Cloud | None:
result = await db.execute(
select(Cloud).options(*_cloud_options()).where(Cloud.id == cloud_id)
)
return result.scalar_one_or_none()
@router.get("", response_model=PageOut[CloudOut])
async def list_gallery_clouds(
db: DbSession,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
type_id: int | None = Query(None, ge=1),
search: str | None = Query(None, max_length=80),
) -> PageOut[CloudOut]:
filters = [Cloud.status == "approved", Cloud.is_hidden.is_(False)]
if type_id is not None:
filters.append(Cloud.cloud_type_id == type_id)
data_query = select(Cloud).options(*_cloud_options())
count_query = select(func.count(Cloud.id))
term = _normalize_text(search)
if term and term.startswith("@"):
username = term[1:].strip()
if not username:
return PageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
data_query = data_query.join(Cloud.user).join(User.profile)
count_query = count_query.join(Cloud.user).join(User.profile)
filters.append(Profile.username.ilike(f"%{username}%"))
elif term:
data_query = data_query.outerjoin(Cloud.cloud_type)
count_query = count_query.outerjoin(Cloud.cloud_type)
filters.append(
or_(
CloudType.name.ilike(f"%{term}%"),
CloudType.name_en.ilike(f"%{term}%"),
Cloud.custom_cloud_type.ilike(f"%{term}%"),
)
)
total = await db.scalar(count_query.where(*filters)) or 0
result = await db.execute(
data_query.where(*filters)
.order_by(Cloud.created_at.desc(), Cloud.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
return PageOut(
items=[cloud_out(item, include_private=False) for item in result.scalars().unique().all()],
page=page,
page_size=page_size,
total=total,
total_pages=max(1, math.ceil(total / page_size)),
)
@router.get("/map", response_model=list[CloudOut])
async def list_map_clouds(
db: DbSession,
start: datetime,
end: datetime,
time_field: Literal["captured_at", "created_at"] = "captured_at",
limit: int = Query(1000, ge=1, le=1000),
) -> list[CloudOut]:
start = _normalize_required_datetime(start)
end = _normalize_required_datetime(end)
if start >= end:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="结束时间必须晚于开始时间")
field = Cloud.captured_at if time_field == "captured_at" else Cloud.created_at
result = await db.execute(
select(Cloud)
.options(*_cloud_options())
.where(
Cloud.status == "approved",
Cloud.is_hidden.is_(False),
Cloud.latitude.is_not(None),
Cloud.longitude.is_not(None),
field >= start,
field < end,
)
.order_by(field.asc(), Cloud.id.asc())
.limit(limit)
)
return [cloud_out(item, include_private=False) for item in result.scalars().unique().all()]
@router.get("/{cloud_id}", response_model=CloudOut)
async def get_cloud(cloud_id: uuid.UUID, db: DbSession, viewer: OptionalUser) -> CloudOut:
cloud = await _get_cloud(db, cloud_id)
if not cloud:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
can_manage = bool(viewer and (viewer.id == cloud.user_id or viewer.profile.role == "admin"))
if not can_manage and (cloud.status != "approved" or cloud.is_hidden):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
return cloud_out(cloud, include_private=can_manage)
@router.post("", response_model=CloudCreateOut, status_code=status.HTTP_201_CREATED)
async def create_cloud(
db: DbSession,
user: CurrentUser,
image: Annotated[UploadFile, File()],
cloud_type_id: Annotated[int | None, Form()] = None,
custom_cloud_type: Annotated[str | None, Form(max_length=80)] = None,
latitude: Annotated[float | None, Form(ge=-90, le=90)] = None,
longitude: Annotated[float | None, Form(ge=-180, le=180)] = None,
location_name: Annotated[str | None, Form(max_length=120)] = None,
description: Annotated[str | None, Form(max_length=2000)] = None,
captured_at: Annotated[datetime | None, Form()] = None,
is_hidden: Annotated[bool, Form()] = False,
) -> CloudCreateOut:
custom_cloud_type = _normalize_text(custom_cloud_type)
if (cloud_type_id is None) == (custom_cloud_type is None):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="必须选择一个云类型,或填写自定义云类型",
)
if (latitude is None) != (longitude is None):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="经纬度必须同时填写")
cloud_type = await _require_cloud_type(db, cloud_type_id) if cloud_type_id else None
image_path, thumbnail_path = await save_cloud_image(image, user.id)
cloud = Cloud(
user_id=user.id,
cloud_type_id=cloud_type_id,
custom_cloud_type=custom_cloud_type,
image_path=image_path,
thumbnail_path=thumbnail_path,
latitude=_blur_coordinate(latitude),
longitude=_blur_coordinate(longitude),
location_name=_normalize_text(location_name),
description=_normalize_text(description),
captured_at=_normalize_datetime(captured_at) or datetime.now(timezone.utc),
status="pending",
is_hidden=is_hidden,
)
db.add(cloud)
unlocked_badge: BadgeOut | None = None
try:
await db.flush()
if cloud_type:
insert_factory = pg_insert if db.bind and db.bind.dialect.name == "postgresql" else sqlite_insert
insert_result = await db.execute(
insert_factory(UserCollection)
.values(
user_id=user.id,
cloud_type_id=cloud_type.id,
first_cloud_id=cloud.id,
)
.on_conflict_do_nothing(
index_elements=[UserCollection.user_id, UserCollection.cloud_type_id]
)
.returning(UserCollection.unlocked_at)
)
unlocked_at = insert_result.scalar_one_or_none()
if unlocked_at:
unlocked_badge = BadgeOut(
cloud_type_id=cloud_type.id,
cloud_name=cloud_type.name,
cloud_name_en=cloud_type.name_en,
rarity=cloud_type.rarity,
unlocked_at=unlocked_at,
)
await db.commit()
except Exception:
await db.rollback()
await delete_files([image_path, thumbnail_path])
raise
saved = await _get_cloud(db, cloud.id)
if not saved:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="图片记录创建失败")
return CloudCreateOut(cloud=cloud_out(saved), unlocked_badge=unlocked_badge)
@router.patch("/{cloud_id}", response_model=CloudOut)
async def update_cloud(
cloud_id: uuid.UUID,
payload: CloudUpdateIn,
db: DbSession,
user: CurrentUser,
) -> CloudOut:
cloud = await _get_cloud(db, cloud_id)
if not cloud:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
if cloud.user_id != user.id and user.profile.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改其他用户的图片")
changes = payload.model_dump(exclude_unset=True)
final_type_id = changes.get("cloud_type_id", cloud.cloud_type_id)
final_custom = _normalize_text(changes.get("custom_cloud_type", cloud.custom_cloud_type))
if "cloud_type_id" in changes and changes["cloud_type_id"] is not None and "custom_cloud_type" not in changes:
final_custom = None
if "custom_cloud_type" in changes and final_custom is not None and "cloud_type_id" not in changes:
final_type_id = None
if (final_type_id is None) == (final_custom is None):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="必须选择一个云类型,或填写自定义云类型",
)
if final_type_id is not None:
await _require_cloud_type(db, final_type_id)
if "cloud_type_id" in changes or "custom_cloud_type" in changes:
cloud.cloud_type_id = final_type_id
cloud.custom_cloud_type = final_custom
if "latitude" in changes:
cloud.latitude = _blur_coordinate(changes["latitude"])
cloud.longitude = _blur_coordinate(changes["longitude"])
if "location_name" in changes:
cloud.location_name = _normalize_text(changes["location_name"])
if "description" in changes:
cloud.description = _normalize_text(changes["description"])
if "captured_at" in changes:
cloud.captured_at = _normalize_datetime(changes["captured_at"])
if "is_hidden" in changes:
cloud.is_hidden = changes["is_hidden"]
await db.commit()
updated = await _get_cloud(db, cloud.id)
if not updated:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
return cloud_out(updated)
@router.delete("/{cloud_id}", response_model=DeleteResultOut)
async def delete_cloud(cloud_id: uuid.UUID, db: DbSession, user: CurrentUser) -> DeleteResultOut:
cloud = await _get_cloud(db, cloud_id)
if not cloud:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
if cloud.user_id != user.id and user.profile.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能删除其他用户的图片")
paths = [cloud.image_path, cloud.thumbnail_path]
await db.delete(cloud)
await db.commit()
await delete_files(paths)
return DeleteResultOut(deleted=1)
@router.post("/batch-delete", response_model=DeleteResultOut)
async def batch_delete_clouds(
payload: BatchIdsIn,
db: DbSession,
user: CurrentUser,
) -> DeleteResultOut:
result = await db.execute(
select(Cloud).where(Cloud.id.in_(payload.ids), Cloud.user_id == user.id)
)
clouds = result.scalars().all()
if len(clouds) != len(payload.ids):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部分图片不存在或不属于当前用户")
paths = [path for cloud in clouds for path in (cloud.image_path, cloud.thumbnail_path)]
for cloud in clouds:
await db.delete(cloud)
await db.commit()
await delete_files(paths)
return DeleteResultOut(deleted=len(clouds))
+47
View File
@@ -0,0 +1,47 @@
from fastapi import APIRouter
from sqlalchemy import select
from sqlalchemy.orm import joinedload
from app.deps import CurrentUser, DbSession
from app.models import UserCollection
from app.schemas import CollectionCloudOut, CollectionOut
from app.serializers import cloud_type_summary, media_url
router = APIRouter(prefix="/collections", tags=["图鉴"])
@router.get("/me", response_model=list[CollectionOut])
async def get_my_collection(user: CurrentUser, db: DbSession) -> list[CollectionOut]:
result = await db.execute(
select(UserCollection)
.options(joinedload(UserCollection.cloud_type), joinedload(UserCollection.first_cloud))
.where(UserCollection.user_id == user.id)
.order_by(UserCollection.unlocked_at.asc())
)
output: list[CollectionOut] = []
for item in result.scalars().unique().all():
first_cloud = item.first_cloud
output.append(
CollectionOut(
id=item.id,
user_id=item.user_id,
cloud_type_id=item.cloud_type_id,
first_cloud_id=item.first_cloud_id,
unlocked_at=item.unlocked_at,
cloud_type=cloud_type_summary(item.cloud_type),
first_cloud=(
CollectionCloudOut(
id=first_cloud.id,
image_url=media_url(first_cloud.image_path) or "",
thumbnail_url=media_url(first_cloud.thumbnail_path) or "",
captured_at=first_cloud.captured_at,
created_at=first_cloud.created_at,
location_name=first_cloud.location_name,
)
if first_cloud
else None
),
)
)
return output
+19
View File
@@ -0,0 +1,19 @@
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import text
from app.deps import DbSession
router = APIRouter(tags=["系统"])
@router.get("/health")
async def health(db: DbSession) -> dict[str, str]:
try:
await db.execute(text("select 1"))
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="数据库不可用",
) from exc
return {"status": "ok", "database": "ok"}
+73
View File
@@ -0,0 +1,73 @@
import math
import uuid
from fastapi import APIRouter, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import joinedload
from app.deps import CurrentUser, DbSession, OptionalUser
from app.models import Cloud, Profile, User
from app.schemas import CloudOut, PageOut, ProfileOut, ProfileUpdateIn, PublicProfileOut
from app.serializers import cloud_out, profile_out, public_profile_out
router = APIRouter(prefix="/profiles", tags=["用户资料"])
@router.get("/{user_id}", response_model=PublicProfileOut)
async def get_profile(user_id: uuid.UUID, db: DbSession) -> PublicProfileOut:
profile = await db.get(Profile, user_id)
if not profile:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
return public_profile_out(profile)
@router.patch("/me", response_model=ProfileOut)
async def update_my_profile(
payload: ProfileUpdateIn,
user: CurrentUser,
db: DbSession,
) -> ProfileOut:
user.profile.username = payload.username
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="这个昵称已经被使用") from exc
return profile_out(user.profile)
@router.get("/{user_id}/clouds", response_model=PageOut[CloudOut])
async def get_profile_clouds(
user_id: uuid.UUID,
db: DbSession,
viewer: OptionalUser,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
) -> PageOut[CloudOut]:
if not await db.get(Profile, user_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
can_manage = bool(viewer and (viewer.id == user_id or viewer.profile.role == "admin"))
filters = [Cloud.user_id == user_id]
if not can_manage:
filters.extend([Cloud.status == "approved", Cloud.is_hidden.is_(False)])
total = await db.scalar(select(func.count()).select_from(Cloud).where(*filters)) or 0
result = await db.execute(
select(Cloud)
.options(joinedload(Cloud.user).joinedload(User.profile), joinedload(Cloud.cloud_type))
.where(*filters)
.order_by(Cloud.captured_at.desc().nulls_last(), Cloud.created_at.desc(), Cloud.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
items = [cloud_out(item, include_private=can_manage) for item in result.scalars().unique().all()]
return PageOut(
items=items,
page=page,
page_size=page_size,
total=total,
total_pages=max(1, math.ceil(total / page_size)),
)