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 @@
"""OpenCloud backend package."""
+83
View File
@@ -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()
+39
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+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)),
)
+254
View File
@@ -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
+71
View File
@@ -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
View File
@@ -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()
+97
View File
@@ -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,
)
+1
View File
@@ -0,0 +1 @@
"""Application services."""
+48
View File
@@ -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如果不是你发起的请求,请忽略此邮件。",
)
+107
View File
@@ -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)