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
+44
View File
@@ -0,0 +1,44 @@
APP_NAME=OpenCloud API
ENVIRONMENT=development
API_V1_PREFIX=/api/v1
DEBUG=true
# 数据库连接仅从 .env 读取。PostgreSQL 必须使用 asyncpg 异步驱动。
DATABASE_URL=postgresql+asyncpg://opencloud:opencloud@localhost:5432/opencloud
DATABASE_POOL_SIZE=10
DATABASE_MAX_OVERFLOW=20
# 使用 `openssl rand -hex 32` 生成,生产环境必须替换。
SECRET_KEY=adfc41bfca48562fc188db9f5c7eb3c8b990b793cef163f519bb72cac422979d
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=30
FRONTEND_URL=http://localhost:5173
PUBLIC_BASE_URL=http://localhost:8000
CORS_ORIGINS=http://localhost:5173
REFRESH_COOKIE_NAME=opencloud_refresh
COOKIE_SECURE=false
COOKIE_SAMESITE=lax
# COOKIE_DOMAIN=
UPLOAD_DIR=data/uploads
MAX_UPLOAD_BYTES=20971520
MAX_IMAGE_PIXELS=50000000
THUMBNAIL_MAX_EDGE=640
# 开发环境会把确认链接写入后端日志;生产环境改为 smtp。
EMAIL_DELIVERY_MODE=console
# SMTP_HOST=smtp.example.com
SMTP_PORT=587
# SMTP_USERNAME=
# SMTP_PASSWORD=
SMTP_STARTTLS=true
EMAIL_FROM=OpenCloud <no-reply@opencloud.local>
EMAIL_CONFIRMATION_EXPIRE_HOURS=24
PASSWORD_RESET_EXPIRE_MINUTES=30
# 运行 `uv run python -m app.seed` 时可选创建初始管理员。
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=change-this-admin-password
ADMIN_USERNAME=管理员
+16
View File
@@ -0,0 +1,16 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
# Local configuration and runtime data
.env
data/
.pytest_cache/
*.sqlite3
+1
View File
@@ -0,0 +1 @@
3.13
+187
View File
@@ -0,0 +1,187 @@
# OpenCloud Backend
OpenCloud 的 FastAPI 后端,替代原项目中的 Supabase Database、Auth 和 Storage。后端提供邮箱密码认证、可撤销刷新会话、云图上传与缩略图、地图/画廊查询、图鉴解锁、个人主页和管理员审核。
## 技术栈
- Python 3.13、FastAPI、Pydantic
- PostgreSQL 17、SQLAlchemy 2(异步)、Alembic
- Argon2 密码哈希、JWT Access Token、数据库 Refresh Session
- 本地持久化图片目录、Pillow 图片验证和缩略图生成
- SMTP 邮箱确认与密码重置
## 本地启动
复制配置并生成开发密钥:
```bash
cp .env.example .env
openssl rand -hex 32
```
把生成值写入 `.env``SECRET_KEY`,然后安装依赖:
```bash
uv sync
```
准备一个可访问的 PostgreSQL 数据库,并在 `.env` 中填写实际连接信息:
```dotenv
DATABASE_URL=postgresql+asyncpg://用户名:密码@主机:5432/数据库名
```
应用和 Alembic 都只从 `.env` 读取数据库连接,不需要在其他配置文件中重复填写。
创建表并写入十种云类型、可选初始管理员:
```bash
uv run alembic upgrade head
uv run python -m app.seed
```
启动开发服务器:
```bash
uv run uvicorn app.main:app --reload
```
- API`http://localhost:8000/api/v1`
- Swagger`http://localhost:8000/docs`
- 健康检查:`http://localhost:8000/api/v1/health`
## 认证约定
登录成功后响应包含有效期较短的 `access_token`。前端在请求头中发送:
```text
Authorization: Bearer <access_token>
```
Refresh Token 只保存在 HttpOnly Cookie 中。前端调用 `/api/v1/auth/refresh``/api/v1/auth/logout` 时必须允许 Cookie
```ts
fetch(url, { credentials: 'include' })
```
开发环境默认 `EMAIL_DELIVERY_MODE=console`,邮箱确认和密码重置链接会输出到后端日志。如需真实发信,请在 `.env` 中配置 SMTP 参数并将 `EMAIL_DELIVERY_MODE` 改为 `smtp`
## 主要接口
### 认证
- `POST /api/v1/auth/register`
- `POST /api/v1/auth/resend-confirmation`
- `POST /api/v1/auth/confirm-email`
- `POST /api/v1/auth/login`
- `POST /api/v1/auth/refresh`
- `POST /api/v1/auth/logout`
- `GET /api/v1/auth/me`
- `POST /api/v1/auth/forgot-password`
- `POST /api/v1/auth/reset-password`
- `PATCH /api/v1/auth/password`
### 云图和图鉴
- `GET/POST /api/v1/clouds`
- `GET /api/v1/clouds/map`
- `GET/PATCH/DELETE /api/v1/clouds/{id}`
- `POST /api/v1/clouds/batch-delete`
- `GET /api/v1/cloud-types`
- `GET /api/v1/cloud-types/{id}/clouds`
- `GET /api/v1/collections/me`
上传使用 `multipart/form-data`,文件字段为 `image`;服务端自行验证、重编码原图并生成 JPEG 缩略图。新图片状态始终为 `pending``user_id` 从 Access Token 获取,经纬度在服务端保留两位小数。
### 用户和管理后台
- `GET /api/v1/profiles/{user_id}`
- `PATCH /api/v1/profiles/me`
- `GET /api/v1/profiles/{user_id}/clouds`
- `GET /api/v1/admin/stats`
- `GET/PATCH /api/v1/admin/users`
- `GET /api/v1/admin/clouds`
- `PATCH /api/v1/admin/clouds/status`
- `PATCH /api/v1/admin/clouds/visibility`
- `POST /api/v1/admin/clouds/batch-delete`
精确的请求和响应模型以 Swagger/OpenAPI 为准。
## 环境变量
所有可调配置均可在 `.env.example` 中找到。首次运行先复制该文件:
```bash
cp .env.example .env
```
其中关键配置包括:
- `DATABASE_URL`:异步 SQLAlchemy 连接串,PostgreSQL 使用 `postgresql+asyncpg://`
- `SECRET_KEY`:JWT 签名密钥,生产环境至少 32 个字符
- `FRONTEND_URL`:认证邮件中的前端地址
- `PUBLIC_BASE_URL`API 和图片公开地址
- `CORS_ORIGINS`:逗号分隔的前端来源
- `UPLOAD_DIR`:持久化图片目录
- `EMAIL_DELIVERY_MODE``console``smtp`
- `ADMIN_EMAIL``ADMIN_PASSWORD`:执行 seed 时可选创建管理员
生产环境还必须设置 `ENVIRONMENT=production``COOKIE_SECURE=true`,并通过 HTTPS 访问。
## 数据库迁移(Alembic
Alembic 用于管理数据库结构版本,作用类似于数据库结构的 Git。它会记录建表、增加字段、创建索引等变更,使不同环境中的数据库结构与代码保持一致,但不会负责启动 PostgreSQL 服务。
项目中的相关文件:
- `app/models.py`SQLAlchemy 数据模型,也是当前期望的数据库结构
- `alembic/versions/`:按版本保存数据库迁移脚本
- `alembic/env.py`:加载模型,并通过 `.env` 获取 `DATABASE_URL`
- `alembic.ini`:Alembic 的基础配置,不保存数据库账号或密码
首次初始化数据库或拉取到新的迁移后,升级到最新版本:
```bash
uv run alembic upgrade head
```
修改 SQLAlchemy 模型后,先生成迁移脚本并检查生成内容,再执行升级:
```bash
uv run alembic revision --autogenerate -m "describe change"
uv run alembic upgrade head
```
查看当前数据库版本和迁移历史:
```bash
uv run alembic current
uv run alembic history
```
回退最近一次迁移:
```bash
uv run alembic downgrade -1
```
回退操作可能导致字段或数据被删除,执行前应先检查迁移脚本并备份数据库。
应用使用 SQLAlchemy 连接池,所有外键和主要画廊、地图、个人主页、审核查询均有对应索引。应用数据库账号不应使用 PostgreSQL 超级用户。
## 验证
```bash
uv run pytest -q
uv run python -m compileall -q app main.py
uv run alembic upgrade head --sql
```
测试覆盖注册、邮箱确认、登录、资料修改、上传、缩略图、图鉴解锁、管理员审核、画廊、地图和删除的完整流程。
## 部署注意事项
- `data/uploads` 必须挂载到持久化磁盘并纳入备份。
- PostgreSQL 和上传目录需要分别制定备份与恢复方案。
- 单机部署可由 FastAPI 提供 `/media`;高流量部署建议由 Nginx/Caddy 直接服务该目录。
- `is_hidden` 与原 Supabase 公共 bucket 行为一致,只阻止页面发现,知道图片 URL 的人仍可直接访问。若需要真正私密图片,应改为鉴权下载或短期签名 URL。
+38
View File
@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
path_separator = os
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+53
View File
@@ -0,0 +1,53 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from app import models # noqa: F401
from app.config import settings
from app.database import Base
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=settings.database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_async_migrations())
+24
View File
@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,195 @@
"""Initial OpenCloud schema.
Revision ID: 20260718_0001
Revises:
Create Date: 2026-07-18
"""
from typing import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260718_0001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("email", sa.Text(), nullable=False),
sa.Column("password_hash", sa.Text(), nullable=False),
sa.Column("email_verified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
)
op.create_table(
"cloud_types",
sa.Column("id", sa.SmallInteger(), autoincrement=False, nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("name_en", sa.Text(), nullable=False),
sa.Column("genus", sa.Text(), nullable=False),
sa.Column("rarity", sa.Text(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("icon_url", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint("rarity IN ('common', 'uncommon', 'rare')", name="ck_cloud_types_rarity"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
sa.UniqueConstraint("name_en"),
)
op.create_table(
"profiles",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("username", sa.Text(), nullable=False),
sa.Column("avatar_path", sa.Text(), nullable=True),
sa.Column("role", sa.Text(), server_default="user", nullable=False),
sa.Column("is_disabled", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint("length(trim(username)) >= 2", name="ck_profiles_username_length"),
sa.CheckConstraint("role IN ('user', 'admin')", name="ck_profiles_role"),
sa.ForeignKeyConstraint(["id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("username"),
)
op.create_table(
"refresh_sessions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("token_hash", sa.Text(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("replaced_by_id", sa.Uuid(), nullable=True),
sa.Column("user_agent", sa.Text(), nullable=True),
sa.Column("ip_address", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["replaced_by_id"], ["refresh_sessions.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index("ix_refresh_sessions_user_id", "refresh_sessions", ["user_id"])
op.create_index("ix_refresh_sessions_replaced_by_id", "refresh_sessions", ["replaced_by_id"])
op.create_index(
"ix_refresh_sessions_active_user",
"refresh_sessions",
["user_id", "expires_at"],
postgresql_where=sa.text("revoked_at IS NULL"),
)
op.create_table(
"email_tokens",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("purpose", sa.Text(), nullable=False),
sa.Column("token_hash", sa.Text(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint("purpose IN ('confirm_email', 'reset_password')", name="ck_email_tokens_purpose"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index("ix_email_tokens_user_purpose", "email_tokens", ["user_id", "purpose"])
op.create_table(
"clouds",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("cloud_type_id", sa.SmallInteger(), nullable=True),
sa.Column("custom_cloud_type", sa.Text(), nullable=True),
sa.Column("image_path", sa.Text(), nullable=False),
sa.Column("thumbnail_path", sa.Text(), nullable=False),
sa.Column("latitude", sa.Numeric(4, 2), nullable=True),
sa.Column("longitude", sa.Numeric(5, 2), nullable=True),
sa.Column("location_name", sa.Text(), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("captured_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("status", sa.Text(), server_default="pending", nullable=False),
sa.Column("is_hidden", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint("status IN ('pending', 'approved', 'rejected')", name="ck_clouds_status"),
sa.CheckConstraint(
"((latitude IS NULL AND longitude IS NULL) OR (latitude IS NOT NULL AND longitude IS NOT NULL))",
name="ck_clouds_coordinate_pair",
),
sa.CheckConstraint("latitude IS NULL OR latitude BETWEEN -90 AND 90", name="ck_clouds_latitude"),
sa.CheckConstraint("longitude IS NULL OR longitude BETWEEN -180 AND 180", name="ck_clouds_longitude"),
sa.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",
),
sa.ForeignKeyConstraint(["cloud_type_id"], ["cloud_types.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("image_path"),
sa.UniqueConstraint("thumbnail_path"),
)
op.create_index("ix_clouds_user_id", "clouds", ["user_id"])
op.create_index("ix_clouds_cloud_type_id", "clouds", ["cloud_type_id"])
op.create_index(
"ix_clouds_user_captured",
"clouds",
["user_id", sa.text("captured_at DESC"), sa.text("created_at DESC")],
)
op.create_index("ix_clouds_status_created", "clouds", ["status", sa.text("created_at DESC")])
op.create_index(
"ix_clouds_public_gallery",
"clouds",
[sa.text("created_at DESC"), sa.text("id DESC")],
postgresql_where=sa.text("status = 'approved' AND is_hidden = false"),
)
op.create_index(
"ix_clouds_public_map",
"clouds",
["captured_at"],
postgresql_where=sa.text(
"status = 'approved' AND is_hidden = false AND latitude IS NOT NULL AND longitude IS NOT NULL"
),
)
op.create_index(
"ix_clouds_public_type",
"clouds",
["cloud_type_id", sa.text("captured_at DESC")],
postgresql_where=sa.text("status = 'approved' AND is_hidden = false"),
)
op.create_table(
"user_collections",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("cloud_type_id", sa.SmallInteger(), nullable=False),
sa.Column("first_cloud_id", sa.Uuid(), nullable=True),
sa.Column("unlocked_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["cloud_type_id"], ["cloud_types.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["first_cloud_id"], ["clouds.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "cloud_type_id", name="uq_user_collections_user_type"),
)
op.create_index("ix_user_collections_user_id", "user_collections", ["user_id"])
op.create_index("ix_user_collections_cloud_type_id", "user_collections", ["cloud_type_id"])
op.create_index("ix_user_collections_first_cloud_id", "user_collections", ["first_cloud_id"])
def downgrade() -> None:
op.drop_table("user_collections")
op.drop_table("clouds")
op.drop_table("email_tokens")
op.drop_table("refresh_sessions")
op.drop_table("profiles")
op.drop_table("cloud_types")
op.drop_table("users")
+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)
+4
View File
@@ -0,0 +1,4 @@
from app.main import app
__all__ = ["app"]
+34
View File
@@ -0,0 +1,34 @@
[project]
name = "opencloud-backend"
version = "0.1.0"
description = "FastAPI backend for OpenCloud"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"aiosmtplib>=4.0.1",
"alembic>=1.16.4",
"asyncpg>=0.30.0",
"email-validator>=2.2.0",
"fastapi>=0.116.1",
"pillow>=11.3.0",
"pwdlib[argon2]>=0.2.1",
"pydantic-settings>=2.10.1",
"pyjwt>=2.10.1",
"python-multipart>=0.0.20",
"sqlalchemy[asyncio]>=2.0.41",
"uvicorn[standard]>=0.35.0",
]
[dependency-groups]
dev = [
"aiosqlite>=0.21.0",
"httpx>=0.28.1",
"pytest>=8.4.1",
"pytest-asyncio>=1.1.0",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
pythonpath = ["."]
addopts = "-p no:cacheprovider"
+51
View File
@@ -0,0 +1,51 @@
import asyncio
import os
import shutil
import tempfile
import uuid
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
TEST_ID = uuid.uuid4().hex
TEST_DB = Path(tempfile.gettempdir()) / f"opencloud-{TEST_ID}.sqlite3"
TEST_UPLOADS = Path(tempfile.gettempdir()) / f"opencloud-{TEST_ID}-uploads"
os.environ["ENVIRONMENT"] = "test"
os.environ["DATABASE_URL"] = f"sqlite+aiosqlite:///{TEST_DB}"
os.environ["SECRET_KEY"] = "test-secret-key-that-is-long-enough-for-opencloud"
os.environ["PUBLIC_BASE_URL"] = "http://testserver"
os.environ["FRONTEND_URL"] = "http://testserver"
os.environ["CORS_ORIGINS"] = "http://testserver"
os.environ["UPLOAD_DIR"] = str(TEST_UPLOADS)
os.environ["EMAIL_DELIVERY_MODE"] = "console"
os.environ["ADMIN_EMAIL"] = "admin@example.com"
os.environ["ADMIN_PASSWORD"] = "admin-password-123"
os.environ["ADMIN_USERNAME"] = "测试管理员"
from app.database import Base, engine # noqa: E402
from app.main import app # noqa: E402
from app.seed import seed # noqa: E402
async def _prepare_database() -> None:
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
await seed()
async def _close_database() -> None:
await engine.dispose()
@pytest.fixture(scope="session")
def client() -> Iterator[TestClient]:
asyncio.run(_prepare_database())
with TestClient(app) as test_client:
yield test_client
asyncio.run(_close_database())
TEST_DB.unlink(missing_ok=True)
shutil.rmtree(TEST_UPLOADS, ignore_errors=True)
+148
View File
@@ -0,0 +1,148 @@
import io
from fastapi.testclient import TestClient
from PIL import Image
from app.routers import auth as auth_router
def _png_bytes() -> bytes:
output = io.BytesIO()
Image.new("RGB", (1200, 800), color=(125, 196, 240)).save(output, format="PNG")
return output.getvalue()
def _bearer(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def test_openapi_contains_all_feature_groups(client: TestClient) -> None:
response = client.get("/openapi.json")
assert response.status_code == 200
paths = response.json()["paths"]
expected = {
"/api/v1/auth/register",
"/api/v1/auth/login",
"/api/v1/cloud-types",
"/api/v1/clouds",
"/api/v1/clouds/map",
"/api/v1/collections/me",
"/api/v1/profiles/{user_id}/clouds",
"/api/v1/admin/stats",
}
assert expected <= set(paths)
def test_complete_user_cloud_and_admin_flow(client: TestClient, monkeypatch) -> None:
captured_tokens: dict[str, str] = {}
async def capture_confirmation(_: str, token: str) -> None:
captured_tokens["confirmation"] = token
monkeypatch.setattr(auth_router, "send_confirmation_email", capture_confirmation)
health = client.get("/api/v1/health")
assert health.status_code == 200
registered = client.post(
"/api/v1/auth/register",
json={
"email": "observer@example.com",
"password": "observer-password-123",
"username": "观云者",
},
)
assert registered.status_code == 201
assert "confirmation" in captured_tokens
confirmed = client.post(
"/api/v1/auth/confirm-email",
json={"token": captured_tokens["confirmation"]},
)
assert confirmed.status_code == 200
login = client.post(
"/api/v1/auth/login",
json={"email": "observer@example.com", "password": "observer-password-123"},
)
assert login.status_code == 200
user_token = login.json()["access_token"]
user_id = login.json()["user"]["id"]
me = client.get("/api/v1/auth/me", headers=_bearer(user_token))
assert me.status_code == 200
assert me.json()["profile"]["username"] == "观云者"
renamed = client.patch(
"/api/v1/profiles/me",
headers=_bearer(user_token),
json={"username": "天空观察员"},
)
assert renamed.status_code == 200
upload = client.post(
"/api/v1/clouds",
headers=_bearer(user_token),
files={"image": ("cloud.png", _png_bytes(), "image/png")},
data={
"cloud_type_id": "1",
"latitude": "31.2345",
"longitude": "121.4737",
"location_name": "上海",
"description": "测试云图",
"captured_at": "2026-07-18T10:00:00+08:00",
"is_hidden": "false",
},
)
assert upload.status_code == 201, upload.text
cloud = upload.json()["cloud"]
cloud_id = cloud["id"]
assert cloud["status"] == "pending"
assert cloud["latitude"] == 31.23
assert upload.json()["unlocked_badge"]["cloud_type_id"] == 1
collection = client.get("/api/v1/collections/me", headers=_bearer(user_token))
assert collection.status_code == 200
assert len(collection.json()) == 1
hidden_from_gallery = client.get("/api/v1/clouds")
assert hidden_from_gallery.json()["total"] == 0
admin_login = client.post(
"/api/v1/auth/login",
json={"email": "admin@example.com", "password": "admin-password-123"},
)
assert admin_login.status_code == 200
admin_token = admin_login.json()["access_token"]
approved = client.patch(
"/api/v1/admin/clouds/status",
headers=_bearer(admin_token),
json={"ids": [cloud_id], "status": "approved"},
)
assert approved.status_code == 200
assert approved.json()["updated"] == 1
gallery = client.get("/api/v1/clouds", params={"search": "积云"})
assert gallery.status_code == 200
assert gallery.json()["total"] == 1
map_clouds = client.get(
"/api/v1/clouds/map",
params={
"start": "2026-07-18T00:00:00+08:00",
"end": "2026-07-19T00:00:00+08:00",
},
)
assert map_clouds.status_code == 200
assert len(map_clouds.json()) == 1
public_profile_clouds = client.get(f"/api/v1/profiles/{user_id}/clouds")
assert public_profile_clouds.status_code == 200
assert public_profile_clouds.json()["total"] == 1
deleted = client.delete(f"/api/v1/clouds/{cloud_id}", headers=_bearer(user_token))
assert deleted.status_code == 200
assert deleted.json()["deleted"] == 1
logout = client.post("/api/v1/auth/logout")
assert logout.status_code == 200
Generated
+1082
View File
File diff suppressed because it is too large Load Diff