first commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""OpenCloud backend package."""
|
||||
@@ -0,0 +1,83 @@
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
app_name: str = "OpenCloud API"
|
||||
environment: Literal["development", "test", "production"] = "development"
|
||||
api_v1_prefix: str = "/api/v1"
|
||||
debug: bool = False
|
||||
|
||||
database_url: str
|
||||
database_pool_size: int = 10
|
||||
database_max_overflow: int = 20
|
||||
|
||||
secret_key: str
|
||||
jwt_algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 15
|
||||
refresh_token_expire_days: int = 30
|
||||
|
||||
refresh_cookie_name: str = "opencloud_refresh"
|
||||
cookie_secure: bool = False
|
||||
cookie_samesite: Literal["lax", "strict", "none"] = "lax"
|
||||
cookie_domain: str | None = None
|
||||
|
||||
frontend_url: str = "http://localhost:5173"
|
||||
public_base_url: str = "http://localhost:8000"
|
||||
cors_origins: str = "http://localhost:5173"
|
||||
|
||||
upload_dir: Path = Path("data/uploads")
|
||||
max_upload_bytes: int = 20 * 1024 * 1024
|
||||
max_image_pixels: int = 50_000_000
|
||||
thumbnail_max_edge: int = 640
|
||||
|
||||
email_delivery_mode: Literal["console", "smtp"] = "console"
|
||||
smtp_host: str | None = None
|
||||
smtp_port: int = 587
|
||||
smtp_username: str | None = None
|
||||
smtp_password: str | None = None
|
||||
smtp_starttls: bool = True
|
||||
email_from: str = "OpenCloud <no-reply@opencloud.local>"
|
||||
email_confirmation_expire_hours: int = 24
|
||||
password_reset_expire_minutes: int = 30
|
||||
|
||||
admin_email: str | None = None
|
||||
admin_password: str | None = None
|
||||
admin_username: str = "管理员"
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [item.strip() for item in self.cors_origins.split(",") if item.strip()]
|
||||
|
||||
@property
|
||||
def media_base_url(self) -> str:
|
||||
return f"{self.public_base_url.rstrip('/')}/media"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_production_settings(self) -> "Settings":
|
||||
if self.environment == "production":
|
||||
if self.secret_key.startswith("change-me") or len(self.secret_key) < 32:
|
||||
raise ValueError("生产环境必须配置至少 32 个字符的 SECRET_KEY")
|
||||
if not self.cookie_secure:
|
||||
raise ValueError("生产环境必须启用 COOKIE_SECURE")
|
||||
if self.email_delivery_mode == "smtp" and not self.smtp_host:
|
||||
raise ValueError("SMTP 模式必须配置 SMTP_HOST")
|
||||
return self
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,39 @@
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine_options: dict[str, object] = {
|
||||
"pool_pre_ping": True,
|
||||
}
|
||||
if not settings.database_url.startswith("sqlite"):
|
||||
engine_options.update(
|
||||
pool_size=settings.database_pool_size,
|
||||
max_overflow=settings.database_max_overflow,
|
||||
)
|
||||
else:
|
||||
engine_options["poolclass"] = NullPool
|
||||
|
||||
engine = create_async_engine(settings.database_url, **engine_options)
|
||||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||
|
||||
|
||||
async def get_db() -> AsyncIterator[AsyncSession]:
|
||||
async with SessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import RefreshSession, User
|
||||
from app.security import decode_access_token
|
||||
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_db)]
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def _resolve_user(
|
||||
credentials: HTTPAuthorizationCredentials,
|
||||
db: AsyncSession,
|
||||
) -> User:
|
||||
if credentials.scheme.lower() != "bearer":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证方式")
|
||||
try:
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.join(
|
||||
RefreshSession,
|
||||
(RefreshSession.id == payload.session_id)
|
||||
& (RefreshSession.user_id == User.id),
|
||||
)
|
||||
.options(selectinload(User.profile))
|
||||
.where(
|
||||
User.id == payload.user_id,
|
||||
RefreshSession.revoked_at.is_(None),
|
||||
RefreshSession.expires_at > datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录状态已失效")
|
||||
if user.profile.is_disabled:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用")
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
db: DbSession,
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)],
|
||||
) -> User:
|
||||
if credentials is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录")
|
||||
return await _resolve_user(credentials, db)
|
||||
|
||||
|
||||
async def get_optional_user(
|
||||
db: DbSession,
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)],
|
||||
) -> User | None:
|
||||
if credentials is None:
|
||||
return None
|
||||
return await _resolve_user(credentials, db)
|
||||
|
||||
|
||||
async def get_admin_user(user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||
if user.profile.role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
|
||||
return user
|
||||
|
||||
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
OptionalUser = Annotated[User | None, Depends(get_optional_user)]
|
||||
AdminUser = Annotated[User, Depends(get_admin_user)]
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import admin, auth, cloud_types, clouds, collections, health, profiles
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
version="0.1.0",
|
||||
debug=settings.debug,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origin_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
)
|
||||
|
||||
for router in (
|
||||
health.router,
|
||||
auth.router,
|
||||
profiles.router,
|
||||
cloud_types.router,
|
||||
clouds.router,
|
||||
collections.router,
|
||||
admin.router,
|
||||
):
|
||||
app.include_router(router, prefix=settings.api_v1_prefix)
|
||||
|
||||
app.mount("/media", StaticFiles(directory=settings.upload_dir, check_dir=False), name="media")
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root() -> dict[str, str]:
|
||||
return {"name": settings.app_name, "docs": "/docs"}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Numeric,
|
||||
SmallInteger,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
Uuid,
|
||||
func,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
|
||||
class User(Base, TimestampMixin):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
email: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
profile: Mapped["Profile"] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan", uselist=False, lazy="raise"
|
||||
)
|
||||
clouds: Mapped[list["Cloud"]] = relationship(back_populates="user", lazy="raise")
|
||||
|
||||
|
||||
class Profile(Base):
|
||||
__tablename__ = "profiles"
|
||||
__table_args__ = (
|
||||
CheckConstraint("length(trim(username)) >= 2", name="ck_profiles_username_length"),
|
||||
CheckConstraint("role IN ('user', 'admin')", name="ck_profiles_role"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
username: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||
avatar_path: Mapped[str | None] = mapped_column(Text)
|
||||
role: Mapped[str] = mapped_column(Text, nullable=False, default="user", server_default="user")
|
||||
is_disabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default=text("false")
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="profile", lazy="raise")
|
||||
|
||||
|
||||
class RefreshSession(Base):
|
||||
__tablename__ = "refresh_sessions"
|
||||
__table_args__ = (
|
||||
Index("ix_refresh_sessions_user_id", "user_id"),
|
||||
Index("ix_refresh_sessions_replaced_by_id", "replaced_by_id"),
|
||||
Index(
|
||||
"ix_refresh_sessions_active_user",
|
||||
"user_id",
|
||||
"expires_at",
|
||||
postgresql_where=text("revoked_at IS NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
token_hash: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
replaced_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid, ForeignKey("refresh_sessions.id", ondelete="SET NULL")
|
||||
)
|
||||
user_agent: Mapped[str | None] = mapped_column(Text)
|
||||
ip_address: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship(lazy="raise")
|
||||
|
||||
|
||||
class EmailToken(Base):
|
||||
__tablename__ = "email_tokens"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"purpose IN ('confirm_email', 'reset_password')", name="ck_email_tokens_purpose"
|
||||
),
|
||||
Index("ix_email_tokens_user_purpose", "user_id", "purpose"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
purpose: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
token_hash: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
|
||||
class CloudType(Base):
|
||||
__tablename__ = "cloud_types"
|
||||
__table_args__ = (
|
||||
CheckConstraint("rarity IN ('common', 'uncommon', 'rare')", name="ck_cloud_types_rarity"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(SmallInteger, primary_key=True, autoincrement=False)
|
||||
name: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||
name_en: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||
genus: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
rarity: Mapped[str] = mapped_column(Text, nullable=False, default="common")
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
icon_url: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
|
||||
class Cloud(Base, TimestampMixin):
|
||||
__tablename__ = "clouds"
|
||||
__table_args__ = (
|
||||
CheckConstraint("status IN ('pending', 'approved', 'rejected')", name="ck_clouds_status"),
|
||||
CheckConstraint(
|
||||
"((latitude IS NULL AND longitude IS NULL) OR "
|
||||
"(latitude IS NOT NULL AND longitude IS NOT NULL))",
|
||||
name="ck_clouds_coordinate_pair",
|
||||
),
|
||||
CheckConstraint("latitude IS NULL OR latitude BETWEEN -90 AND 90", name="ck_clouds_latitude"),
|
||||
CheckConstraint(
|
||||
"longitude IS NULL OR longitude BETWEEN -180 AND 180", name="ck_clouds_longitude"
|
||||
),
|
||||
CheckConstraint(
|
||||
"((cloud_type_id IS NOT NULL AND custom_cloud_type IS NULL) OR "
|
||||
"(cloud_type_id IS NULL AND custom_cloud_type IS NOT NULL "
|
||||
"AND length(trim(custom_cloud_type)) > 0))",
|
||||
name="ck_clouds_type_choice",
|
||||
),
|
||||
Index("ix_clouds_user_id", "user_id"),
|
||||
Index("ix_clouds_cloud_type_id", "cloud_type_id"),
|
||||
Index("ix_clouds_user_captured", "user_id", text("captured_at DESC"), text("created_at DESC")),
|
||||
Index("ix_clouds_status_created", "status", text("created_at DESC")),
|
||||
Index(
|
||||
"ix_clouds_public_gallery",
|
||||
text("created_at DESC"),
|
||||
text("id DESC"),
|
||||
postgresql_where=text("status = 'approved' AND is_hidden = false"),
|
||||
),
|
||||
Index(
|
||||
"ix_clouds_public_map",
|
||||
"captured_at",
|
||||
postgresql_where=text(
|
||||
"status = 'approved' AND is_hidden = false "
|
||||
"AND latitude IS NOT NULL AND longitude IS NOT NULL"
|
||||
),
|
||||
),
|
||||
Index(
|
||||
"ix_clouds_public_type",
|
||||
"cloud_type_id",
|
||||
text("captured_at DESC"),
|
||||
postgresql_where=text("status = 'approved' AND is_hidden = false"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
cloud_type_id: Mapped[int | None] = mapped_column(
|
||||
SmallInteger, ForeignKey("cloud_types.id", ondelete="SET NULL")
|
||||
)
|
||||
custom_cloud_type: Mapped[str | None] = mapped_column(Text)
|
||||
image_path: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||
thumbnail_path: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||
latitude: Mapped[Decimal | None] = mapped_column(Numeric(4, 2))
|
||||
longitude: Mapped[Decimal | None] = mapped_column(Numeric(5, 2))
|
||||
location_name: Mapped[str | None] = mapped_column(Text)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
captured_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
status: Mapped[str] = mapped_column(Text, nullable=False, default="pending", server_default="pending")
|
||||
is_hidden: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default=text("false")
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="clouds", lazy="raise")
|
||||
cloud_type: Mapped[CloudType | None] = relationship(lazy="raise")
|
||||
|
||||
|
||||
class UserCollection(Base):
|
||||
__tablename__ = "user_collections"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "cloud_type_id", name="uq_user_collections_user_type"),
|
||||
Index("ix_user_collections_user_id", "user_id"),
|
||||
Index("ix_user_collections_cloud_type_id", "cloud_type_id"),
|
||||
Index("ix_user_collections_first_cloud_id", "first_cloud_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
cloud_type_id: Mapped[int] = mapped_column(
|
||||
SmallInteger, ForeignKey("cloud_types.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
first_cloud_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid, ForeignKey("clouds.id", ondelete="SET NULL")
|
||||
)
|
||||
unlocked_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
cloud_type: Mapped[CloudType] = relationship(lazy="raise")
|
||||
first_cloud: Mapped[Cloud | None] = relationship(lazy="raise")
|
||||
@@ -0,0 +1 @@
|
||||
"""API routers."""
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)),
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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)),
|
||||
)
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Generic, Literal, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
|
||||
|
||||
|
||||
Role = Literal["user", "admin"]
|
||||
CloudStatus = Literal["pending", "approved", "rejected"]
|
||||
Rarity = Literal["common", "uncommon", "rare"]
|
||||
|
||||
|
||||
class MessageOut(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class RegisterIn(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
username: str = Field(min_length=2, max_length=32)
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def strip_username(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if len(value) < 2:
|
||||
raise ValueError("昵称至少需要 2 个字符")
|
||||
return value
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class TokenIn(BaseModel):
|
||||
token: str = Field(min_length=20, max_length=512)
|
||||
|
||||
|
||||
class ForgotPasswordIn(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordIn(TokenIn):
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class ChangePasswordIn(BaseModel):
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class ProfileOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
username: str
|
||||
avatar_url: str | None = None
|
||||
role: Role
|
||||
is_disabled: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PublicProfileOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
username: str
|
||||
avatar_url: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
email: EmailStr
|
||||
email_verified: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AuthOut(BaseModel):
|
||||
access_token: str
|
||||
token_type: Literal["bearer"] = "bearer"
|
||||
expires_in: int
|
||||
user: UserOut
|
||||
profile: ProfileOut
|
||||
|
||||
|
||||
class MeOut(BaseModel):
|
||||
user: UserOut
|
||||
profile: ProfileOut
|
||||
|
||||
|
||||
class ProfileUpdateIn(BaseModel):
|
||||
username: str = Field(min_length=2, max_length=32)
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def strip_username(cls, value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
|
||||
class CloudTypeSummary(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
name_en: str
|
||||
rarity: Rarity
|
||||
|
||||
|
||||
class CloudTypeOut(CloudTypeSummary):
|
||||
genus: str
|
||||
description: str | None
|
||||
icon_url: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CloudOwnerOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
username: str
|
||||
|
||||
|
||||
class CloudOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
cloud_type_id: int | None
|
||||
custom_cloud_type: str | None
|
||||
image_url: str
|
||||
thumbnail_url: str
|
||||
latitude: float | None
|
||||
longitude: float | None
|
||||
location_name: str | None
|
||||
description: str | None
|
||||
captured_at: datetime | None
|
||||
status: CloudStatus
|
||||
is_hidden: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
cloud_type: CloudTypeSummary | None
|
||||
owner: CloudOwnerOut | None
|
||||
cloud_type_name: str
|
||||
cloud_type_rarity: Rarity
|
||||
username: str
|
||||
|
||||
|
||||
class CloudUpdateIn(BaseModel):
|
||||
cloud_type_id: int | None = None
|
||||
custom_cloud_type: str | None = Field(default=None, max_length=80)
|
||||
latitude: float | None = Field(default=None, ge=-90, le=90)
|
||||
longitude: float | None = Field(default=None, ge=-180, le=180)
|
||||
location_name: str | None = Field(default=None, max_length=120)
|
||||
description: str | None = Field(default=None, max_length=2000)
|
||||
captured_at: datetime | None = None
|
||||
is_hidden: bool | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_coordinates(self) -> "CloudUpdateIn":
|
||||
provided = self.model_fields_set
|
||||
if ("latitude" in provided) != ("longitude" in provided):
|
||||
raise ValueError("经纬度必须同时提供")
|
||||
if "latitude" in provided and ((self.latitude is None) != (self.longitude is None)):
|
||||
raise ValueError("经纬度必须同时为空或同时有值")
|
||||
return self
|
||||
|
||||
|
||||
class BadgeOut(BaseModel):
|
||||
cloud_type_id: int
|
||||
cloud_name: str
|
||||
cloud_name_en: str
|
||||
rarity: Rarity
|
||||
unlocked_at: datetime
|
||||
|
||||
|
||||
class CloudCreateOut(BaseModel):
|
||||
cloud: CloudOut
|
||||
unlocked_badge: BadgeOut | None
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class PageOut(BaseModel, Generic[T]):
|
||||
items: list[T]
|
||||
page: int
|
||||
page_size: int
|
||||
total: int
|
||||
total_pages: int
|
||||
|
||||
|
||||
class BatchIdsIn(BaseModel):
|
||||
ids: list[uuid.UUID] = Field(min_length=1, max_length=100)
|
||||
|
||||
@field_validator("ids")
|
||||
@classmethod
|
||||
def unique_ids(cls, value: list[uuid.UUID]) -> list[uuid.UUID]:
|
||||
return list(dict.fromkeys(value))
|
||||
|
||||
|
||||
class DeleteResultOut(BaseModel):
|
||||
deleted: int
|
||||
|
||||
|
||||
class CollectionCloudOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
image_url: str
|
||||
thumbnail_url: str
|
||||
captured_at: datetime | None
|
||||
created_at: datetime
|
||||
location_name: str | None
|
||||
|
||||
|
||||
class CollectionOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
cloud_type_id: int
|
||||
first_cloud_id: uuid.UUID | None
|
||||
unlocked_at: datetime
|
||||
cloud_type: CloudTypeSummary
|
||||
first_cloud: CollectionCloudOut | None
|
||||
|
||||
|
||||
class AdminStatsOut(BaseModel):
|
||||
users: int
|
||||
images: int
|
||||
today_uploads: int
|
||||
pending: int
|
||||
approved: int
|
||||
rejected: int
|
||||
hidden: int
|
||||
|
||||
|
||||
class AdminUserOut(BaseModel):
|
||||
user: UserOut
|
||||
profile: ProfileOut
|
||||
|
||||
|
||||
class AdminUserUpdateIn(BaseModel):
|
||||
role: Role | None = None
|
||||
is_disabled: bool | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_change(self) -> "AdminUserUpdateIn":
|
||||
if not self.model_fields_set:
|
||||
raise ValueError("至少需要提供一个修改字段")
|
||||
return self
|
||||
|
||||
|
||||
class AdminCloudStatusIn(BatchIdsIn):
|
||||
status: CloudStatus
|
||||
|
||||
|
||||
class AdminCloudVisibilityIn(BatchIdsIn):
|
||||
is_hidden: bool
|
||||
|
||||
|
||||
class BatchUpdateOut(BaseModel):
|
||||
updated: int
|
||||
@@ -0,0 +1,71 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
from pwdlib import PasswordHash
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
password_hash = PasswordHash.recommended()
|
||||
DUMMY_PASSWORD_HASH = password_hash.hash("opencloud-dummy-password")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AccessTokenPayload:
|
||||
user_id: uuid.UUID
|
||||
session_id: uuid.UUID
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return password_hash.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, encoded_hash: str) -> bool:
|
||||
return password_hash.verify(password, encoded_hash)
|
||||
|
||||
|
||||
def create_random_token() -> str:
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def create_access_token(user_id: uuid.UUID, session_id: uuid.UUID) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"sid": str(session_id),
|
||||
"type": "access",
|
||||
"iat": now,
|
||||
"exp": now + timedelta(minutes=settings.access_token_expire_minutes),
|
||||
"jti": str(uuid.uuid4()),
|
||||
}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> AccessTokenPayload:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.jwt_algorithm])
|
||||
if payload.get("type") != "access":
|
||||
raise InvalidTokenError("wrong token type")
|
||||
return AccessTokenPayload(
|
||||
user_id=uuid.UUID(payload["sub"]),
|
||||
session_id=uuid.UUID(payload["sid"]),
|
||||
)
|
||||
except (InvalidTokenError, KeyError, TypeError, ValueError) as exc:
|
||||
raise ValueError("无效或已过期的访问令牌") from exc
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def refresh_expires_at() -> datetime:
|
||||
return utc_now() + timedelta(days=settings.refresh_token_expire_days)
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import asyncio
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database import SessionLocal
|
||||
from app.models import CloudType, Profile, User
|
||||
from app.security import hash_password, utc_now
|
||||
|
||||
|
||||
CLOUD_TYPES = [
|
||||
{"id": 1, "name": "积云", "name_en": "Cumulus", "genus": "低云族", "rarity": "common", "description": "轮廓分明、底部较平,常呈棉花团状的对流云。"},
|
||||
{"id": 2, "name": "层云", "name_en": "Stratus", "genus": "低云族", "rarity": "common", "description": "低而均匀的灰色云层,常覆盖大部分天空。"},
|
||||
{"id": 3, "name": "卷云", "name_en": "Cirrus", "genus": "高云族", "rarity": "common", "description": "由冰晶组成的纤细白色云丝,常呈羽毛或马尾状。"},
|
||||
{"id": 4, "name": "积雨云", "name_en": "Cumulonimbus", "genus": "直展云族", "rarity": "uncommon", "description": "垂直发展旺盛的巨大云体,可能带来雷雨、冰雹和强风。"},
|
||||
{"id": 5, "name": "层积云", "name_en": "Stratocumulus", "genus": "低云族", "rarity": "common", "description": "成片或成层排列的低云块,常带有明暗相间的纹理。"},
|
||||
{"id": 6, "name": "高积云", "name_en": "Altocumulus", "genus": "中云族", "rarity": "common", "description": "由较小云块组成的中层云,常呈波纹或鱼鳞状。"},
|
||||
{"id": 7, "name": "高层云", "name_en": "Altostratus", "genus": "中云族", "rarity": "uncommon", "description": "灰蓝色的中层云幕,太阳通常像隔着毛玻璃一样可见。"},
|
||||
{"id": 8, "name": "雨层云", "name_en": "Nimbostratus", "genus": "中低云族", "rarity": "uncommon", "description": "厚而暗的降水云层,常带来持续性的雨或雪。"},
|
||||
{"id": 9, "name": "卷层云", "name_en": "Cirrostratus", "genus": "高云族", "rarity": "rare", "description": "薄而透明的冰晶云幕,常在日月周围形成光环。"},
|
||||
{"id": 10, "name": "卷积云", "name_en": "Cirrocumulus", "genus": "高云族", "rarity": "rare", "description": "细小白色云粒规则排列形成的高云,俗称鱼鳞天。"},
|
||||
]
|
||||
|
||||
|
||||
async def seed() -> None:
|
||||
async with SessionLocal() as db:
|
||||
for values in CLOUD_TYPES:
|
||||
cloud_type = await db.get(CloudType, values["id"])
|
||||
if cloud_type:
|
||||
for key, value in values.items():
|
||||
setattr(cloud_type, key, value)
|
||||
else:
|
||||
db.add(CloudType(**values))
|
||||
|
||||
if settings.admin_email and settings.admin_password:
|
||||
email = settings.admin_email.strip().lower()
|
||||
result = await db.execute(
|
||||
select(User).options(selectinload(User.profile)).where(User.email == email)
|
||||
)
|
||||
admin = result.scalar_one_or_none()
|
||||
if admin:
|
||||
admin.profile.role = "admin"
|
||||
admin.profile.is_disabled = False
|
||||
admin.email_verified_at = admin.email_verified_at or utc_now()
|
||||
else:
|
||||
admin = User(
|
||||
email=email,
|
||||
password_hash=hash_password(settings.admin_password),
|
||||
email_verified_at=utc_now(),
|
||||
)
|
||||
admin.profile = Profile(username=settings.admin_username, role="admin")
|
||||
db.add(admin)
|
||||
await db.commit()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
asyncio.run(seed())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,97 @@
|
||||
from app.config import settings
|
||||
from app.models import Cloud, CloudType, Profile, User
|
||||
from app.schemas import (
|
||||
CloudOut,
|
||||
CloudOwnerOut,
|
||||
CloudTypeOut,
|
||||
CloudTypeSummary,
|
||||
ProfileOut,
|
||||
PublicProfileOut,
|
||||
UserOut,
|
||||
)
|
||||
|
||||
|
||||
def media_url(path: str | None) -> str | None:
|
||||
if not path:
|
||||
return None
|
||||
return f"{settings.media_base_url}/{path.lstrip('/')}"
|
||||
|
||||
|
||||
def profile_out(profile: Profile) -> ProfileOut:
|
||||
return ProfileOut(
|
||||
id=profile.id,
|
||||
username=profile.username,
|
||||
avatar_url=media_url(profile.avatar_path),
|
||||
role=profile.role,
|
||||
is_disabled=profile.is_disabled,
|
||||
created_at=profile.created_at,
|
||||
)
|
||||
|
||||
|
||||
def public_profile_out(profile: Profile) -> PublicProfileOut:
|
||||
return PublicProfileOut(
|
||||
id=profile.id,
|
||||
username=profile.username,
|
||||
avatar_url=media_url(profile.avatar_path),
|
||||
created_at=profile.created_at,
|
||||
)
|
||||
|
||||
|
||||
def user_out(user: User) -> UserOut:
|
||||
return UserOut(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
email_verified=user.email_verified_at is not None,
|
||||
created_at=user.created_at,
|
||||
)
|
||||
|
||||
|
||||
def cloud_type_summary(cloud_type: CloudType) -> CloudTypeSummary:
|
||||
return CloudTypeSummary(
|
||||
id=cloud_type.id,
|
||||
name=cloud_type.name,
|
||||
name_en=cloud_type.name_en,
|
||||
rarity=cloud_type.rarity,
|
||||
)
|
||||
|
||||
|
||||
def cloud_type_out(cloud_type: CloudType) -> CloudTypeOut:
|
||||
return CloudTypeOut(
|
||||
id=cloud_type.id,
|
||||
name=cloud_type.name,
|
||||
name_en=cloud_type.name_en,
|
||||
genus=cloud_type.genus,
|
||||
rarity=cloud_type.rarity,
|
||||
description=cloud_type.description,
|
||||
icon_url=cloud_type.icon_url,
|
||||
created_at=cloud_type.created_at,
|
||||
)
|
||||
|
||||
|
||||
def cloud_out(cloud: Cloud, *, include_private: bool = True) -> CloudOut:
|
||||
cloud_type = cloud.cloud_type
|
||||
profile = cloud.user.profile
|
||||
type_name = cloud_type.name if cloud_type else (cloud.custom_cloud_type or "未知")
|
||||
rarity = cloud_type.rarity if cloud_type else "common"
|
||||
return CloudOut(
|
||||
id=cloud.id,
|
||||
user_id=cloud.user_id,
|
||||
cloud_type_id=cloud.cloud_type_id,
|
||||
custom_cloud_type=cloud.custom_cloud_type,
|
||||
image_url=media_url(cloud.image_path) or "",
|
||||
thumbnail_url=media_url(cloud.thumbnail_path) or "",
|
||||
latitude=float(cloud.latitude) if cloud.latitude is not None else None,
|
||||
longitude=float(cloud.longitude) if cloud.longitude is not None else None,
|
||||
location_name=cloud.location_name,
|
||||
description=cloud.description,
|
||||
captured_at=cloud.captured_at,
|
||||
status=cloud.status if include_private else "approved",
|
||||
is_hidden=cloud.is_hidden if include_private else False,
|
||||
created_at=cloud.created_at,
|
||||
updated_at=cloud.updated_at,
|
||||
cloud_type=cloud_type_summary(cloud_type) if cloud_type else None,
|
||||
owner=CloudOwnerOut(id=profile.id, username=profile.username),
|
||||
cloud_type_name=type_name,
|
||||
cloud_type_rarity=rarity,
|
||||
username=profile.username,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Application services."""
|
||||
@@ -0,0 +1,48 @@
|
||||
import logging
|
||||
from email.message import EmailMessage
|
||||
|
||||
import aiosmtplib
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _send(subject: str, recipient: str, text: str) -> None:
|
||||
if settings.email_delivery_mode == "console":
|
||||
logger.warning("开发邮件\nTo: %s\nSubject: %s\n%s", recipient, subject, text)
|
||||
return
|
||||
|
||||
message = EmailMessage()
|
||||
message["From"] = settings.email_from
|
||||
message["To"] = recipient
|
||||
message["Subject"] = subject
|
||||
message.set_content(text)
|
||||
|
||||
await aiosmtplib.send(
|
||||
message,
|
||||
hostname=settings.smtp_host,
|
||||
port=settings.smtp_port,
|
||||
username=settings.smtp_username,
|
||||
password=settings.smtp_password,
|
||||
start_tls=settings.smtp_starttls,
|
||||
)
|
||||
|
||||
|
||||
async def send_confirmation_email(recipient: str, token: str) -> None:
|
||||
url = f"{settings.frontend_url.rstrip('/')}/auth/confirm?token={token}"
|
||||
await _send(
|
||||
"确认你的 OpenCloud 邮箱",
|
||||
recipient,
|
||||
f"请打开下面的链接完成邮箱确认:\n\n{url}\n\n链接将在指定时间后失效。",
|
||||
)
|
||||
|
||||
|
||||
async def send_password_reset_email(recipient: str, token: str) -> None:
|
||||
url = f"{settings.frontend_url.rstrip('/')}/auth/reset-password?token={token}"
|
||||
await _send(
|
||||
"重置你的 OpenCloud 密码",
|
||||
recipient,
|
||||
f"请打开下面的链接重置密码:\n\n{url}\n\n如果不是你发起的请求,请忽略此邮件。",
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException, UploadFile, status
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
ALLOWED_FORMATS = {"JPEG": "jpg", "PNG": "png", "WEBP": "webp"}
|
||||
|
||||
|
||||
def _process_image(raw: bytes) -> tuple[bytes, str, bytes]:
|
||||
Image.MAX_IMAGE_PIXELS = settings.max_image_pixels
|
||||
try:
|
||||
with Image.open(io.BytesIO(raw)) as source:
|
||||
if source.width * source.height > settings.max_image_pixels:
|
||||
raise ValueError("图片像素尺寸过大")
|
||||
source.load()
|
||||
image_format = source.format
|
||||
if image_format not in ALLOWED_FORMATS:
|
||||
raise ValueError("仅支持 JPEG、PNG 和 WebP 图片")
|
||||
|
||||
extension = ALLOWED_FORMATS[image_format]
|
||||
normalized = io.BytesIO()
|
||||
if image_format == "PNG":
|
||||
source.save(normalized, format="PNG", optimize=True)
|
||||
else:
|
||||
rgb = source.convert("RGB")
|
||||
output_format = "JPEG" if image_format == "JPEG" else "WEBP"
|
||||
rgb.save(normalized, format=output_format, quality=92, optimize=True)
|
||||
|
||||
thumbnail = source.copy()
|
||||
thumbnail.thumbnail(
|
||||
(settings.thumbnail_max_edge, settings.thumbnail_max_edge),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
thumbnail_bytes = io.BytesIO()
|
||||
thumbnail.convert("RGB").save(
|
||||
thumbnail_bytes,
|
||||
format="JPEG",
|
||||
quality=78,
|
||||
optimize=True,
|
||||
)
|
||||
return normalized.getvalue(), extension, thumbnail_bytes.getvalue()
|
||||
except (UnidentifiedImageError, OSError, Image.DecompressionBombError, ValueError) as exc:
|
||||
raise ValueError(str(exc) or "图片内容无效") from exc
|
||||
|
||||
|
||||
def _write_files(
|
||||
user_id: uuid.UUID,
|
||||
original: bytes,
|
||||
extension: str,
|
||||
thumbnail: bytes,
|
||||
) -> tuple[str, str]:
|
||||
file_id = uuid.uuid4()
|
||||
relative_dir = Path(str(user_id))
|
||||
image_path = relative_dir / f"{file_id}.{extension}"
|
||||
thumbnail_path = relative_dir / f"{file_id}-thumb.jpg"
|
||||
target_dir = settings.upload_dir / relative_dir
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
(settings.upload_dir / image_path).write_bytes(original)
|
||||
(settings.upload_dir / thumbnail_path).write_bytes(thumbnail)
|
||||
return image_path.as_posix(), thumbnail_path.as_posix()
|
||||
|
||||
|
||||
async def save_cloud_image(file: UploadFile, user_id: uuid.UUID) -> tuple[str, str]:
|
||||
raw = await file.read(settings.max_upload_bytes + 1)
|
||||
if not raw:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="图片不能为空")
|
||||
if len(raw) > settings.max_upload_bytes:
|
||||
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="图片文件过大")
|
||||
try:
|
||||
original, extension, thumbnail = await asyncio.to_thread(_process_image, raw)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"图片无法处理:{exc}",
|
||||
) from exc
|
||||
return await asyncio.to_thread(_write_files, user_id, original, extension, thumbnail)
|
||||
|
||||
|
||||
def _safe_file_path(relative_path: str) -> Path | None:
|
||||
root = settings.upload_dir.resolve()
|
||||
candidate = (root / relative_path).resolve()
|
||||
if candidate == root or root not in candidate.parents:
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
async def delete_files(paths: list[str]) -> None:
|
||||
def remove() -> None:
|
||||
for relative_path in paths:
|
||||
candidate = _safe_file_path(relative_path)
|
||||
if not candidate:
|
||||
logger.error("拒绝删除不安全的文件路径:%s", relative_path)
|
||||
continue
|
||||
try:
|
||||
candidate.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
logger.exception("删除图片失败:%s", relative_path)
|
||||
|
||||
await asyncio.to_thread(remove)
|
||||
Reference in New Issue
Block a user