first commit
This commit is contained in:
@@ -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
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""OpenCloud backend package."""
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import model_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
app_name: str = "OpenCloud API"
|
||||||
|
environment: Literal["development", "test", "production"] = "development"
|
||||||
|
api_v1_prefix: str = "/api/v1"
|
||||||
|
debug: bool = False
|
||||||
|
|
||||||
|
database_url: str
|
||||||
|
database_pool_size: int = 10
|
||||||
|
database_max_overflow: int = 20
|
||||||
|
|
||||||
|
secret_key: str
|
||||||
|
jwt_algorithm: str = "HS256"
|
||||||
|
access_token_expire_minutes: int = 15
|
||||||
|
refresh_token_expire_days: int = 30
|
||||||
|
|
||||||
|
refresh_cookie_name: str = "opencloud_refresh"
|
||||||
|
cookie_secure: bool = False
|
||||||
|
cookie_samesite: Literal["lax", "strict", "none"] = "lax"
|
||||||
|
cookie_domain: str | None = None
|
||||||
|
|
||||||
|
frontend_url: str = "http://localhost:5173"
|
||||||
|
public_base_url: str = "http://localhost:8000"
|
||||||
|
cors_origins: str = "http://localhost:5173"
|
||||||
|
|
||||||
|
upload_dir: Path = Path("data/uploads")
|
||||||
|
max_upload_bytes: int = 20 * 1024 * 1024
|
||||||
|
max_image_pixels: int = 50_000_000
|
||||||
|
thumbnail_max_edge: int = 640
|
||||||
|
|
||||||
|
email_delivery_mode: Literal["console", "smtp"] = "console"
|
||||||
|
smtp_host: str | None = None
|
||||||
|
smtp_port: int = 587
|
||||||
|
smtp_username: str | None = None
|
||||||
|
smtp_password: str | None = None
|
||||||
|
smtp_starttls: bool = True
|
||||||
|
email_from: str = "OpenCloud <no-reply@opencloud.local>"
|
||||||
|
email_confirmation_expire_hours: int = 24
|
||||||
|
password_reset_expire_minutes: int = 30
|
||||||
|
|
||||||
|
admin_email: str | None = None
|
||||||
|
admin_password: str | None = None
|
||||||
|
admin_username: str = "管理员"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origin_list(self) -> list[str]:
|
||||||
|
return [item.strip() for item in self.cors_origins.split(",") if item.strip()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media_base_url(self) -> str:
|
||||||
|
return f"{self.public_base_url.rstrip('/')}/media"
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_production_settings(self) -> "Settings":
|
||||||
|
if self.environment == "production":
|
||||||
|
if self.secret_key.startswith("change-me") or len(self.secret_key) < 32:
|
||||||
|
raise ValueError("生产环境必须配置至少 32 个字符的 SECRET_KEY")
|
||||||
|
if not self.cookie_secure:
|
||||||
|
raise ValueError("生产环境必须启用 COOKIE_SECURE")
|
||||||
|
if self.email_delivery_mode == "smtp" and not self.smtp_host:
|
||||||
|
raise ValueError("SMTP 模式必须配置 SMTP_HOST")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
engine_options: dict[str, object] = {
|
||||||
|
"pool_pre_ping": True,
|
||||||
|
}
|
||||||
|
if not settings.database_url.startswith("sqlite"):
|
||||||
|
engine_options.update(
|
||||||
|
pool_size=settings.database_pool_size,
|
||||||
|
max_overflow=settings.database_max_overflow,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
engine_options["poolclass"] = NullPool
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.database_url, **engine_options)
|
||||||
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncIterator[AsyncSession]:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import RefreshSession, User
|
||||||
|
from app.security import decode_access_token
|
||||||
|
|
||||||
|
|
||||||
|
DbSession = Annotated[AsyncSession, Depends(get_db)]
|
||||||
|
bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> User:
|
||||||
|
if credentials.scheme.lower() != "bearer":
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证方式")
|
||||||
|
try:
|
||||||
|
payload = decode_access_token(credentials.credentials)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(User)
|
||||||
|
.join(
|
||||||
|
RefreshSession,
|
||||||
|
(RefreshSession.id == payload.session_id)
|
||||||
|
& (RefreshSession.user_id == User.id),
|
||||||
|
)
|
||||||
|
.options(selectinload(User.profile))
|
||||||
|
.where(
|
||||||
|
User.id == payload.user_id,
|
||||||
|
RefreshSession.revoked_at.is_(None),
|
||||||
|
RefreshSession.expires_at > datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录状态已失效")
|
||||||
|
if user.profile.is_disabled:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
db: DbSession,
|
||||||
|
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)],
|
||||||
|
) -> User:
|
||||||
|
if credentials is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录")
|
||||||
|
return await _resolve_user(credentials, db)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_optional_user(
|
||||||
|
db: DbSession,
|
||||||
|
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)],
|
||||||
|
) -> User | None:
|
||||||
|
if credentials is None:
|
||||||
|
return None
|
||||||
|
return await _resolve_user(credentials, db)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_admin_user(user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||||
|
if user.profile.role != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
OptionalUser = Annotated[User | None, Depends(get_optional_user)]
|
||||||
|
AdminUser = Annotated[User, Depends(get_admin_user)]
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.routers import admin, auth, cloud_types, clouds, collections, health, profiles
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||||
|
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.app_name,
|
||||||
|
version="0.1.0",
|
||||||
|
debug=settings.debug,
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origin_list,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||||||
|
allow_headers=["Authorization", "Content-Type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
for router in (
|
||||||
|
health.router,
|
||||||
|
auth.router,
|
||||||
|
profiles.router,
|
||||||
|
cloud_types.router,
|
||||||
|
clouds.router,
|
||||||
|
collections.router,
|
||||||
|
admin.router,
|
||||||
|
):
|
||||||
|
app.include_router(router, prefix=settings.api_v1_prefix)
|
||||||
|
|
||||||
|
app.mount("/media", StaticFiles(directory=settings.upload_dir, check_dir=False), name="media")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", include_in_schema=False)
|
||||||
|
async def root() -> dict[str, str]:
|
||||||
|
return {"name": settings.app_name, "docs": "/docs"}
|
||||||
+238
@@ -0,0 +1,238 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Numeric,
|
||||||
|
SmallInteger,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
Uuid,
|
||||||
|
func,
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base, TimestampMixin):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
email: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||||
|
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
profile: Mapped["Profile"] = relationship(
|
||||||
|
back_populates="user", cascade="all, delete-orphan", uselist=False, lazy="raise"
|
||||||
|
)
|
||||||
|
clouds: Mapped[list["Cloud"]] = relationship(back_populates="user", lazy="raise")
|
||||||
|
|
||||||
|
|
||||||
|
class Profile(Base):
|
||||||
|
__tablename__ = "profiles"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("length(trim(username)) >= 2", name="ck_profiles_username_length"),
|
||||||
|
CheckConstraint("role IN ('user', 'admin')", name="ck_profiles_role"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
username: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||||
|
avatar_path: Mapped[str | None] = mapped_column(Text)
|
||||||
|
role: Mapped[str] = mapped_column(Text, nullable=False, default="user", server_default="user")
|
||||||
|
is_disabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False, server_default=text("false")
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship(back_populates="profile", lazy="raise")
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshSession(Base):
|
||||||
|
__tablename__ = "refresh_sessions"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_refresh_sessions_user_id", "user_id"),
|
||||||
|
Index("ix_refresh_sessions_replaced_by_id", "replaced_by_id"),
|
||||||
|
Index(
|
||||||
|
"ix_refresh_sessions_active_user",
|
||||||
|
"user_id",
|
||||||
|
"expires_at",
|
||||||
|
postgresql_where=text("revoked_at IS NULL"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
token_hash: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
replaced_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
Uuid, ForeignKey("refresh_sessions.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
user_agent: Mapped[str | None] = mapped_column(Text)
|
||||||
|
ip_address: Mapped[str | None] = mapped_column(Text)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship(lazy="raise")
|
||||||
|
|
||||||
|
|
||||||
|
class EmailToken(Base):
|
||||||
|
__tablename__ = "email_tokens"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"purpose IN ('confirm_email', 'reset_password')", name="ck_email_tokens_purpose"
|
||||||
|
),
|
||||||
|
Index("ix_email_tokens_user_purpose", "user_id", "purpose"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
purpose: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
token_hash: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CloudType(Base):
|
||||||
|
__tablename__ = "cloud_types"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("rarity IN ('common', 'uncommon', 'rare')", name="ck_cloud_types_rarity"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(SmallInteger, primary_key=True, autoincrement=False)
|
||||||
|
name: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||||
|
name_en: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||||
|
genus: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
rarity: Mapped[str] = mapped_column(Text, nullable=False, default="common")
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
icon_url: Mapped[str | None] = mapped_column(Text)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Cloud(Base, TimestampMixin):
|
||||||
|
__tablename__ = "clouds"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("status IN ('pending', 'approved', 'rejected')", name="ck_clouds_status"),
|
||||||
|
CheckConstraint(
|
||||||
|
"((latitude IS NULL AND longitude IS NULL) OR "
|
||||||
|
"(latitude IS NOT NULL AND longitude IS NOT NULL))",
|
||||||
|
name="ck_clouds_coordinate_pair",
|
||||||
|
),
|
||||||
|
CheckConstraint("latitude IS NULL OR latitude BETWEEN -90 AND 90", name="ck_clouds_latitude"),
|
||||||
|
CheckConstraint(
|
||||||
|
"longitude IS NULL OR longitude BETWEEN -180 AND 180", name="ck_clouds_longitude"
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"((cloud_type_id IS NOT NULL AND custom_cloud_type IS NULL) OR "
|
||||||
|
"(cloud_type_id IS NULL AND custom_cloud_type IS NOT NULL "
|
||||||
|
"AND length(trim(custom_cloud_type)) > 0))",
|
||||||
|
name="ck_clouds_type_choice",
|
||||||
|
),
|
||||||
|
Index("ix_clouds_user_id", "user_id"),
|
||||||
|
Index("ix_clouds_cloud_type_id", "cloud_type_id"),
|
||||||
|
Index("ix_clouds_user_captured", "user_id", text("captured_at DESC"), text("created_at DESC")),
|
||||||
|
Index("ix_clouds_status_created", "status", text("created_at DESC")),
|
||||||
|
Index(
|
||||||
|
"ix_clouds_public_gallery",
|
||||||
|
text("created_at DESC"),
|
||||||
|
text("id DESC"),
|
||||||
|
postgresql_where=text("status = 'approved' AND is_hidden = false"),
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_clouds_public_map",
|
||||||
|
"captured_at",
|
||||||
|
postgresql_where=text(
|
||||||
|
"status = 'approved' AND is_hidden = false "
|
||||||
|
"AND latitude IS NOT NULL AND longitude IS NOT NULL"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_clouds_public_type",
|
||||||
|
"cloud_type_id",
|
||||||
|
text("captured_at DESC"),
|
||||||
|
postgresql_where=text("status = 'approved' AND is_hidden = false"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
cloud_type_id: Mapped[int | None] = mapped_column(
|
||||||
|
SmallInteger, ForeignKey("cloud_types.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
custom_cloud_type: Mapped[str | None] = mapped_column(Text)
|
||||||
|
image_path: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||||
|
thumbnail_path: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||||
|
latitude: Mapped[Decimal | None] = mapped_column(Numeric(4, 2))
|
||||||
|
longitude: Mapped[Decimal | None] = mapped_column(Numeric(5, 2))
|
||||||
|
location_name: Mapped[str | None] = mapped_column(Text)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
captured_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
status: Mapped[str] = mapped_column(Text, nullable=False, default="pending", server_default="pending")
|
||||||
|
is_hidden: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False, server_default=text("false")
|
||||||
|
)
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship(back_populates="clouds", lazy="raise")
|
||||||
|
cloud_type: Mapped[CloudType | None] = relationship(lazy="raise")
|
||||||
|
|
||||||
|
|
||||||
|
class UserCollection(Base):
|
||||||
|
__tablename__ = "user_collections"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "cloud_type_id", name="uq_user_collections_user_type"),
|
||||||
|
Index("ix_user_collections_user_id", "user_id"),
|
||||||
|
Index("ix_user_collections_cloud_type_id", "cloud_type_id"),
|
||||||
|
Index("ix_user_collections_first_cloud_id", "first_cloud_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
cloud_type_id: Mapped[int] = mapped_column(
|
||||||
|
SmallInteger, ForeignKey("cloud_types.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
first_cloud_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
Uuid, ForeignKey("clouds.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
unlocked_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
cloud_type: Mapped[CloudType] = relationship(lazy="raise")
|
||||||
|
first_cloud: Mapped[Cloud | None] = relationship(lazy="raise")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""API routers."""
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import math
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Literal
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, status
|
||||||
|
from sqlalchemy import case, func, select, update
|
||||||
|
from sqlalchemy.orm import joinedload, selectinload
|
||||||
|
|
||||||
|
from app.deps import AdminUser, DbSession
|
||||||
|
from app.models import Cloud, Profile, User
|
||||||
|
from app.schemas import (
|
||||||
|
AdminCloudStatusIn,
|
||||||
|
AdminCloudVisibilityIn,
|
||||||
|
AdminStatsOut,
|
||||||
|
AdminUserOut,
|
||||||
|
AdminUserUpdateIn,
|
||||||
|
BatchIdsIn,
|
||||||
|
BatchUpdateOut,
|
||||||
|
CloudOut,
|
||||||
|
DeleteResultOut,
|
||||||
|
PageOut,
|
||||||
|
)
|
||||||
|
from app.serializers import cloud_out, profile_out, user_out
|
||||||
|
from app.services.storage import delete_files
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin", tags=["管理后台"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_model=AdminStatsOut)
|
||||||
|
async def get_stats(_: AdminUser, db: DbSession) -> AdminStatsOut:
|
||||||
|
local_tz = ZoneInfo("Asia/Shanghai")
|
||||||
|
local_now = datetime.now(local_tz)
|
||||||
|
today_start = local_now.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc)
|
||||||
|
users = await db.scalar(select(func.count()).select_from(User)) or 0
|
||||||
|
row = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
func.count(Cloud.id).label("images"),
|
||||||
|
func.sum(case((Cloud.created_at >= today_start, 1), else_=0)).label("today"),
|
||||||
|
func.sum(case((Cloud.status == "pending", 1), else_=0)).label("pending"),
|
||||||
|
func.sum(case((Cloud.status == "approved", 1), else_=0)).label("approved"),
|
||||||
|
func.sum(case((Cloud.status == "rejected", 1), else_=0)).label("rejected"),
|
||||||
|
func.sum(case((Cloud.is_hidden.is_(True), 1), else_=0)).label("hidden"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
return AdminStatsOut(
|
||||||
|
users=users,
|
||||||
|
images=row.images or 0,
|
||||||
|
today_uploads=row.today or 0,
|
||||||
|
pending=row.pending or 0,
|
||||||
|
approved=row.approved or 0,
|
||||||
|
rejected=row.rejected or 0,
|
||||||
|
hidden=row.hidden or 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users", response_model=PageOut[AdminUserOut])
|
||||||
|
async def list_users(
|
||||||
|
_: AdminUser,
|
||||||
|
db: DbSession,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(100, ge=1, le=100),
|
||||||
|
) -> PageOut[AdminUserOut]:
|
||||||
|
total = await db.scalar(select(func.count()).select_from(User)) or 0
|
||||||
|
result = await db.execute(
|
||||||
|
select(User)
|
||||||
|
.options(selectinload(User.profile))
|
||||||
|
.order_by(User.created_at.desc(), User.id.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
items = [AdminUserOut(user=user_out(user), profile=profile_out(user.profile)) for user in result.scalars()]
|
||||||
|
return PageOut(
|
||||||
|
items=items,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
total=total,
|
||||||
|
total_pages=max(1, math.ceil(total / page_size)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/users/{user_id}", response_model=AdminUserOut)
|
||||||
|
async def update_user(
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
payload: AdminUserUpdateIn,
|
||||||
|
admin: AdminUser,
|
||||||
|
db: DbSession,
|
||||||
|
) -> AdminUserOut:
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).options(selectinload(User.profile)).where(User.id == user_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
|
if user.id == admin.id and (
|
||||||
|
payload.is_disabled is True or (payload.role is not None and payload.role != "admin")
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能禁用自己或移除自己的管理员权限")
|
||||||
|
if payload.role is not None:
|
||||||
|
user.profile.role = payload.role
|
||||||
|
if payload.is_disabled is not None:
|
||||||
|
user.profile.is_disabled = payload.is_disabled
|
||||||
|
await db.commit()
|
||||||
|
return AdminUserOut(user=user_out(user), profile=profile_out(user.profile))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/clouds", response_model=PageOut[CloudOut])
|
||||||
|
async def list_admin_clouds(
|
||||||
|
_: AdminUser,
|
||||||
|
db: DbSession,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(100, ge=1, le=120),
|
||||||
|
cloud_status: Literal["pending", "approved", "rejected"] | None = Query(None, alias="status"),
|
||||||
|
is_hidden: bool | None = None,
|
||||||
|
) -> PageOut[CloudOut]:
|
||||||
|
filters = []
|
||||||
|
if cloud_status:
|
||||||
|
filters.append(Cloud.status == cloud_status)
|
||||||
|
if is_hidden is not None:
|
||||||
|
filters.append(Cloud.is_hidden == is_hidden)
|
||||||
|
total = await db.scalar(select(func.count()).select_from(Cloud).where(*filters)) or 0
|
||||||
|
result = await db.execute(
|
||||||
|
select(Cloud)
|
||||||
|
.options(joinedload(Cloud.user).joinedload(User.profile), joinedload(Cloud.cloud_type))
|
||||||
|
.where(*filters)
|
||||||
|
.order_by(Cloud.created_at.desc(), Cloud.id.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
return PageOut(
|
||||||
|
items=[cloud_out(item) for item in result.scalars().unique().all()],
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
total=total,
|
||||||
|
total_pages=max(1, math.ceil(total / page_size)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_all_cloud_ids(db: DbSession, ids: list[uuid.UUID]) -> None:
|
||||||
|
count = await db.scalar(select(func.count()).select_from(Cloud).where(Cloud.id.in_(ids))) or 0
|
||||||
|
if count != len(ids):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部分图片不存在")
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/clouds/status", response_model=BatchUpdateOut)
|
||||||
|
async def update_cloud_status(
|
||||||
|
payload: AdminCloudStatusIn,
|
||||||
|
_: AdminUser,
|
||||||
|
db: DbSession,
|
||||||
|
) -> BatchUpdateOut:
|
||||||
|
await _require_all_cloud_ids(db, payload.ids)
|
||||||
|
await db.execute(
|
||||||
|
update(Cloud)
|
||||||
|
.where(Cloud.id.in_(payload.ids))
|
||||||
|
.values(status=payload.status, updated_at=func.now())
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return BatchUpdateOut(updated=len(payload.ids))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/clouds/visibility", response_model=BatchUpdateOut)
|
||||||
|
async def update_cloud_visibility(
|
||||||
|
payload: AdminCloudVisibilityIn,
|
||||||
|
_: AdminUser,
|
||||||
|
db: DbSession,
|
||||||
|
) -> BatchUpdateOut:
|
||||||
|
await _require_all_cloud_ids(db, payload.ids)
|
||||||
|
await db.execute(
|
||||||
|
update(Cloud)
|
||||||
|
.where(Cloud.id.in_(payload.ids))
|
||||||
|
.values(is_hidden=payload.is_hidden, updated_at=func.now())
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return BatchUpdateOut(updated=len(payload.ids))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clouds/batch-delete", response_model=DeleteResultOut)
|
||||||
|
async def delete_admin_clouds(
|
||||||
|
payload: BatchIdsIn,
|
||||||
|
_: AdminUser,
|
||||||
|
db: DbSession,
|
||||||
|
) -> DeleteResultOut:
|
||||||
|
result = await db.execute(select(Cloud).where(Cloud.id.in_(payload.ids)))
|
||||||
|
clouds = result.scalars().all()
|
||||||
|
if len(clouds) != len(payload.ids):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部分图片不存在")
|
||||||
|
paths = [path for cloud in clouds for path in (cloud.image_path, cloud.thumbnail_path)]
|
||||||
|
for cloud in clouds:
|
||||||
|
await db.delete(cloud)
|
||||||
|
await db.commit()
|
||||||
|
await delete_files(paths)
|
||||||
|
return DeleteResultOut(deleted=len(clouds))
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.deps import CurrentUser, DbSession
|
||||||
|
from app.models import EmailToken, Profile, RefreshSession, User
|
||||||
|
from app.schemas import (
|
||||||
|
AuthOut,
|
||||||
|
ChangePasswordIn,
|
||||||
|
ForgotPasswordIn,
|
||||||
|
LoginIn,
|
||||||
|
MeOut,
|
||||||
|
MessageOut,
|
||||||
|
RegisterIn,
|
||||||
|
ResetPasswordIn,
|
||||||
|
TokenIn,
|
||||||
|
)
|
||||||
|
from app.security import (
|
||||||
|
DUMMY_PASSWORD_HASH,
|
||||||
|
create_access_token,
|
||||||
|
create_random_token,
|
||||||
|
hash_password,
|
||||||
|
hash_token,
|
||||||
|
refresh_expires_at,
|
||||||
|
utc_now,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
from app.serializers import profile_out, user_out
|
||||||
|
from app.services.email import send_confirmation_email, send_password_reset_email
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _aware_utc(value: datetime) -> datetime:
|
||||||
|
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _client_metadata(request: Request) -> tuple[str | None, str | None]:
|
||||||
|
user_agent = request.headers.get("user-agent")
|
||||||
|
ip_address = request.client.host if request.client else None
|
||||||
|
return user_agent, ip_address
|
||||||
|
|
||||||
|
|
||||||
|
def _set_refresh_cookie(response: Response, token: str) -> None:
|
||||||
|
response.set_cookie(
|
||||||
|
key=settings.refresh_cookie_name,
|
||||||
|
value=token,
|
||||||
|
max_age=settings.refresh_token_expire_days * 24 * 60 * 60,
|
||||||
|
httponly=True,
|
||||||
|
secure=settings.cookie_secure,
|
||||||
|
samesite=settings.cookie_samesite,
|
||||||
|
domain=settings.cookie_domain,
|
||||||
|
path=f"{settings.api_v1_prefix}/auth",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_refresh_cookie(response: Response) -> None:
|
||||||
|
response.delete_cookie(
|
||||||
|
key=settings.refresh_cookie_name,
|
||||||
|
domain=settings.cookie_domain,
|
||||||
|
path=f"{settings.api_v1_prefix}/auth",
|
||||||
|
secure=settings.cookie_secure,
|
||||||
|
httponly=True,
|
||||||
|
samesite=settings.cookie_samesite,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_response(user: User, session: RefreshSession) -> AuthOut:
|
||||||
|
return AuthOut(
|
||||||
|
access_token=create_access_token(user.id, session.id),
|
||||||
|
expires_in=settings.access_token_expire_minutes * 60,
|
||||||
|
user=user_out(user),
|
||||||
|
profile=profile_out(user.profile),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _new_session(user: User, request: Request) -> tuple[RefreshSession, str]:
|
||||||
|
token = create_random_token()
|
||||||
|
user_agent, ip_address = _client_metadata(request)
|
||||||
|
session = RefreshSession(
|
||||||
|
user_id=user.id,
|
||||||
|
token_hash=hash_token(token),
|
||||||
|
expires_at=refresh_expires_at(),
|
||||||
|
user_agent=user_agent,
|
||||||
|
ip_address=ip_address,
|
||||||
|
)
|
||||||
|
return session, token
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_email_token(db: DbSession, user: User, purpose: str, expires_delta: timedelta) -> str:
|
||||||
|
now = utc_now()
|
||||||
|
await db.execute(
|
||||||
|
update(EmailToken)
|
||||||
|
.where(
|
||||||
|
EmailToken.user_id == user.id,
|
||||||
|
EmailToken.purpose == purpose,
|
||||||
|
EmailToken.used_at.is_(None),
|
||||||
|
)
|
||||||
|
.values(used_at=now)
|
||||||
|
)
|
||||||
|
token = create_random_token()
|
||||||
|
db.add(
|
||||||
|
EmailToken(
|
||||||
|
user_id=user.id,
|
||||||
|
purpose=purpose,
|
||||||
|
token_hash=hash_token(token),
|
||||||
|
expires_at=now + expires_delta,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=MessageOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def register(payload: RegisterIn, db: DbSession) -> MessageOut:
|
||||||
|
email = str(payload.email).strip().lower()
|
||||||
|
user = User(email=email, password_hash=hash_password(payload.password))
|
||||||
|
user.profile = Profile(username=payload.username)
|
||||||
|
db.add(user)
|
||||||
|
try:
|
||||||
|
await db.flush()
|
||||||
|
token = await _create_email_token(
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
"confirm_email",
|
||||||
|
timedelta(hours=settings.email_confirmation_expire_hours),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
await db.rollback()
|
||||||
|
message = str(exc.orig).lower()
|
||||||
|
if "email" in message:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该邮箱已被注册") from exc
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="这个昵称已经被使用") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
await send_confirmation_email(user.email, token)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("注册成功,但确认邮件发送失败:%s", user.email)
|
||||||
|
return MessageOut(message="注册成功,请查收确认邮件")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/resend-confirmation", response_model=MessageOut, status_code=status.HTTP_202_ACCEPTED)
|
||||||
|
async def resend_confirmation(payload: ForgotPasswordIn, db: DbSession) -> MessageOut:
|
||||||
|
email = str(payload.email).strip().lower()
|
||||||
|
result = await db.execute(select(User).where(User.email == email))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user and user.email_verified_at is None:
|
||||||
|
token = await _create_email_token(
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
"confirm_email",
|
||||||
|
timedelta(hours=settings.email_confirmation_expire_hours),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
try:
|
||||||
|
await send_confirmation_email(user.email, token)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("确认邮件重发失败:%s", user.email)
|
||||||
|
return MessageOut(message="如果账号存在且尚未确认,确认邮件将会发送")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/confirm-email", response_model=MessageOut)
|
||||||
|
async def confirm_email(payload: TokenIn, db: DbSession) -> MessageOut:
|
||||||
|
now = utc_now()
|
||||||
|
result = await db.execute(
|
||||||
|
select(EmailToken)
|
||||||
|
.where(
|
||||||
|
EmailToken.token_hash == hash_token(payload.token),
|
||||||
|
EmailToken.purpose == "confirm_email",
|
||||||
|
EmailToken.used_at.is_(None),
|
||||||
|
EmailToken.expires_at > now,
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
email_token = result.scalar_one_or_none()
|
||||||
|
if not email_token:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="确认链接无效或已过期")
|
||||||
|
user = await db.get(User, email_token.user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="账号不存在")
|
||||||
|
user.email_verified_at = user.email_verified_at or now
|
||||||
|
email_token.used_at = now
|
||||||
|
await db.commit()
|
||||||
|
return MessageOut(message="邮箱确认成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=AuthOut)
|
||||||
|
async def login(payload: LoginIn, request: Request, response: Response, db: DbSession) -> AuthOut:
|
||||||
|
email = str(payload.email).strip().lower()
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).options(selectinload(User.profile)).where(User.email == email)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
encoded_hash = user.password_hash if user else DUMMY_PASSWORD_HASH
|
||||||
|
password_valid = verify_password(payload.password, encoded_hash)
|
||||||
|
if not user or not password_valid:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码错误")
|
||||||
|
if user.email_verified_at is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="邮箱尚未确认")
|
||||||
|
if user.profile.is_disabled:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用")
|
||||||
|
|
||||||
|
session, refresh_token = _new_session(user, request)
|
||||||
|
db.add(session)
|
||||||
|
await db.commit()
|
||||||
|
_set_refresh_cookie(response, refresh_token)
|
||||||
|
return _auth_response(user, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=AuthOut)
|
||||||
|
async def refresh(request: Request, response: Response, db: DbSession) -> AuthOut:
|
||||||
|
raw_token = request.cookies.get(settings.refresh_cookie_name)
|
||||||
|
if not raw_token:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="缺少刷新令牌")
|
||||||
|
now = utc_now()
|
||||||
|
result = await db.execute(
|
||||||
|
select(RefreshSession)
|
||||||
|
.options(selectinload(RefreshSession.user).selectinload(User.profile))
|
||||||
|
.where(RefreshSession.token_hash == hash_token(raw_token))
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
old_session = result.scalar_one_or_none()
|
||||||
|
if (
|
||||||
|
not old_session
|
||||||
|
or old_session.revoked_at is not None
|
||||||
|
or _aware_utc(old_session.expires_at) <= now
|
||||||
|
):
|
||||||
|
_clear_refresh_cookie(response)
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="刷新令牌无效或已过期")
|
||||||
|
user = old_session.user
|
||||||
|
if user.profile.is_disabled:
|
||||||
|
old_session.revoked_at = now
|
||||||
|
await db.commit()
|
||||||
|
_clear_refresh_cookie(response)
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用")
|
||||||
|
|
||||||
|
new_session, new_token = _new_session(user, request)
|
||||||
|
db.add(new_session)
|
||||||
|
await db.flush()
|
||||||
|
old_session.revoked_at = now
|
||||||
|
old_session.replaced_by_id = new_session.id
|
||||||
|
await db.commit()
|
||||||
|
_set_refresh_cookie(response, new_token)
|
||||||
|
return _auth_response(user, new_session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", response_model=MessageOut)
|
||||||
|
async def logout(request: Request, response: Response, db: DbSession) -> MessageOut:
|
||||||
|
raw_token = request.cookies.get(settings.refresh_cookie_name)
|
||||||
|
if raw_token:
|
||||||
|
result = await db.execute(
|
||||||
|
select(RefreshSession).where(RefreshSession.token_hash == hash_token(raw_token))
|
||||||
|
)
|
||||||
|
session = result.scalar_one_or_none()
|
||||||
|
if session and session.revoked_at is None:
|
||||||
|
session.revoked_at = utc_now()
|
||||||
|
await db.commit()
|
||||||
|
_clear_refresh_cookie(response)
|
||||||
|
return MessageOut(message="已退出登录")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=MeOut)
|
||||||
|
async def me(user: CurrentUser) -> MeOut:
|
||||||
|
return MeOut(user=user_out(user), profile=profile_out(user.profile))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/forgot-password", response_model=MessageOut, status_code=status.HTTP_202_ACCEPTED)
|
||||||
|
async def forgot_password(payload: ForgotPasswordIn, db: DbSession) -> MessageOut:
|
||||||
|
email = str(payload.email).strip().lower()
|
||||||
|
result = await db.execute(select(User).where(User.email == email))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user and user.email_verified_at is not None:
|
||||||
|
token = await _create_email_token(
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
"reset_password",
|
||||||
|
timedelta(minutes=settings.password_reset_expire_minutes),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
try:
|
||||||
|
await send_password_reset_email(user.email, token)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("密码重置邮件发送失败:%s", user.email)
|
||||||
|
return MessageOut(message="如果该邮箱已经注册,密码重置邮件将会发送")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reset-password", response_model=MessageOut)
|
||||||
|
async def reset_password(payload: ResetPasswordIn, db: DbSession) -> MessageOut:
|
||||||
|
now = utc_now()
|
||||||
|
result = await db.execute(
|
||||||
|
select(EmailToken)
|
||||||
|
.where(
|
||||||
|
EmailToken.token_hash == hash_token(payload.token),
|
||||||
|
EmailToken.purpose == "reset_password",
|
||||||
|
EmailToken.used_at.is_(None),
|
||||||
|
EmailToken.expires_at > now,
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
email_token = result.scalar_one_or_none()
|
||||||
|
if not email_token:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重置链接无效或已过期")
|
||||||
|
user = await db.get(User, email_token.user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="账号不存在")
|
||||||
|
user.password_hash = hash_password(payload.password)
|
||||||
|
email_token.used_at = now
|
||||||
|
await db.execute(
|
||||||
|
update(RefreshSession)
|
||||||
|
.where(RefreshSession.user_id == user.id, RefreshSession.revoked_at.is_(None))
|
||||||
|
.values(revoked_at=now)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return MessageOut(message="密码已重置,请重新登录")
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/password", response_model=AuthOut)
|
||||||
|
async def change_password(
|
||||||
|
payload: ChangePasswordIn,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
) -> AuthOut:
|
||||||
|
now = utc_now()
|
||||||
|
user.password_hash = hash_password(payload.password)
|
||||||
|
await db.execute(
|
||||||
|
update(RefreshSession)
|
||||||
|
.where(RefreshSession.user_id == user.id, RefreshSession.revoked_at.is_(None))
|
||||||
|
.values(revoked_at=now)
|
||||||
|
)
|
||||||
|
new_session, refresh_token = _new_session(user, request)
|
||||||
|
db.add(new_session)
|
||||||
|
await db.commit()
|
||||||
|
_set_refresh_cookie(response, refresh_token)
|
||||||
|
return _auth_response(user, new_session)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import math
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
from app.deps import DbSession
|
||||||
|
from app.models import Cloud, CloudType, User
|
||||||
|
from app.schemas import CloudOut, CloudTypeOut, PageOut
|
||||||
|
from app.serializers import cloud_out, cloud_type_out
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/cloud-types", tags=["云类型"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[CloudTypeOut])
|
||||||
|
async def list_cloud_types(db: DbSession) -> list[CloudTypeOut]:
|
||||||
|
result = await db.execute(select(CloudType).order_by(CloudType.id))
|
||||||
|
return [cloud_type_out(item) for item in result.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{cloud_type_id}", response_model=CloudTypeOut)
|
||||||
|
async def get_cloud_type(cloud_type_id: int, db: DbSession) -> CloudTypeOut:
|
||||||
|
cloud_type = await db.get(CloudType, cloud_type_id)
|
||||||
|
if not cloud_type:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="云类型不存在")
|
||||||
|
return cloud_type_out(cloud_type)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{cloud_type_id}/clouds", response_model=PageOut[CloudOut])
|
||||||
|
async def get_cloud_type_clouds(
|
||||||
|
cloud_type_id: int,
|
||||||
|
db: DbSession,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(24, ge=1, le=100),
|
||||||
|
) -> PageOut[CloudOut]:
|
||||||
|
if not await db.get(CloudType, cloud_type_id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="云类型不存在")
|
||||||
|
filters = [
|
||||||
|
Cloud.cloud_type_id == cloud_type_id,
|
||||||
|
Cloud.status == "approved",
|
||||||
|
Cloud.is_hidden.is_(False),
|
||||||
|
]
|
||||||
|
total = await db.scalar(select(func.count()).select_from(Cloud).where(*filters)) or 0
|
||||||
|
result = await db.execute(
|
||||||
|
select(Cloud)
|
||||||
|
.options(joinedload(Cloud.user).joinedload(User.profile), joinedload(Cloud.cloud_type))
|
||||||
|
.where(*filters)
|
||||||
|
.order_by(Cloud.captured_at.desc().nulls_last(), Cloud.created_at.desc(), Cloud.id.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
return PageOut(
|
||||||
|
items=[cloud_out(item, include_private=False) for item in result.scalars().unique().all()],
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
total=total,
|
||||||
|
total_pages=max(1, math.ceil(total / page_size)),
|
||||||
|
)
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import math
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, status
|
||||||
|
from sqlalchemy import func, or_, select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
from app.deps import CurrentUser, DbSession, OptionalUser
|
||||||
|
from app.models import Cloud, CloudType, Profile, User, UserCollection
|
||||||
|
from app.schemas import (
|
||||||
|
BadgeOut,
|
||||||
|
BatchIdsIn,
|
||||||
|
CloudCreateOut,
|
||||||
|
CloudOut,
|
||||||
|
CloudUpdateIn,
|
||||||
|
DeleteResultOut,
|
||||||
|
PageOut,
|
||||||
|
)
|
||||||
|
from app.serializers import cloud_out
|
||||||
|
from app.services.storage import delete_files, save_cloud_image
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/clouds", tags=["云图"])
|
||||||
|
|
||||||
|
|
||||||
|
def _cloud_options():
|
||||||
|
return (
|
||||||
|
joinedload(Cloud.user).joinedload(User.profile),
|
||||||
|
joinedload(Cloud.cloud_type),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_text(value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
stripped = value.strip()
|
||||||
|
return stripped or None
|
||||||
|
|
||||||
|
|
||||||
|
def _blur_coordinate(value: float | None) -> Decimal | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_datetime(value: datetime | None) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_required_datetime(value: datetime) -> datetime:
|
||||||
|
return _normalize_datetime(value) or value.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_cloud_type(db: DbSession, cloud_type_id: int) -> CloudType:
|
||||||
|
cloud_type = await db.get(CloudType, cloud_type_id)
|
||||||
|
if not cloud_type:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="云类型不存在")
|
||||||
|
return cloud_type
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_cloud(db: DbSession, cloud_id: uuid.UUID) -> Cloud | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Cloud).options(*_cloud_options()).where(Cloud.id == cloud_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=PageOut[CloudOut])
|
||||||
|
async def list_gallery_clouds(
|
||||||
|
db: DbSession,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(50, ge=1, le=100),
|
||||||
|
type_id: int | None = Query(None, ge=1),
|
||||||
|
search: str | None = Query(None, max_length=80),
|
||||||
|
) -> PageOut[CloudOut]:
|
||||||
|
filters = [Cloud.status == "approved", Cloud.is_hidden.is_(False)]
|
||||||
|
if type_id is not None:
|
||||||
|
filters.append(Cloud.cloud_type_id == type_id)
|
||||||
|
|
||||||
|
data_query = select(Cloud).options(*_cloud_options())
|
||||||
|
count_query = select(func.count(Cloud.id))
|
||||||
|
term = _normalize_text(search)
|
||||||
|
if term and term.startswith("@"):
|
||||||
|
username = term[1:].strip()
|
||||||
|
if not username:
|
||||||
|
return PageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
|
||||||
|
data_query = data_query.join(Cloud.user).join(User.profile)
|
||||||
|
count_query = count_query.join(Cloud.user).join(User.profile)
|
||||||
|
filters.append(Profile.username.ilike(f"%{username}%"))
|
||||||
|
elif term:
|
||||||
|
data_query = data_query.outerjoin(Cloud.cloud_type)
|
||||||
|
count_query = count_query.outerjoin(Cloud.cloud_type)
|
||||||
|
filters.append(
|
||||||
|
or_(
|
||||||
|
CloudType.name.ilike(f"%{term}%"),
|
||||||
|
CloudType.name_en.ilike(f"%{term}%"),
|
||||||
|
Cloud.custom_cloud_type.ilike(f"%{term}%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
total = await db.scalar(count_query.where(*filters)) or 0
|
||||||
|
result = await db.execute(
|
||||||
|
data_query.where(*filters)
|
||||||
|
.order_by(Cloud.created_at.desc(), Cloud.id.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
return PageOut(
|
||||||
|
items=[cloud_out(item, include_private=False) for item in result.scalars().unique().all()],
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
total=total,
|
||||||
|
total_pages=max(1, math.ceil(total / page_size)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/map", response_model=list[CloudOut])
|
||||||
|
async def list_map_clouds(
|
||||||
|
db: DbSession,
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
time_field: Literal["captured_at", "created_at"] = "captured_at",
|
||||||
|
limit: int = Query(1000, ge=1, le=1000),
|
||||||
|
) -> list[CloudOut]:
|
||||||
|
start = _normalize_required_datetime(start)
|
||||||
|
end = _normalize_required_datetime(end)
|
||||||
|
if start >= end:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="结束时间必须晚于开始时间")
|
||||||
|
field = Cloud.captured_at if time_field == "captured_at" else Cloud.created_at
|
||||||
|
result = await db.execute(
|
||||||
|
select(Cloud)
|
||||||
|
.options(*_cloud_options())
|
||||||
|
.where(
|
||||||
|
Cloud.status == "approved",
|
||||||
|
Cloud.is_hidden.is_(False),
|
||||||
|
Cloud.latitude.is_not(None),
|
||||||
|
Cloud.longitude.is_not(None),
|
||||||
|
field >= start,
|
||||||
|
field < end,
|
||||||
|
)
|
||||||
|
.order_by(field.asc(), Cloud.id.asc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return [cloud_out(item, include_private=False) for item in result.scalars().unique().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{cloud_id}", response_model=CloudOut)
|
||||||
|
async def get_cloud(cloud_id: uuid.UUID, db: DbSession, viewer: OptionalUser) -> CloudOut:
|
||||||
|
cloud = await _get_cloud(db, cloud_id)
|
||||||
|
if not cloud:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
||||||
|
can_manage = bool(viewer and (viewer.id == cloud.user_id or viewer.profile.role == "admin"))
|
||||||
|
if not can_manage and (cloud.status != "approved" or cloud.is_hidden):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
||||||
|
return cloud_out(cloud, include_private=can_manage)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=CloudCreateOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_cloud(
|
||||||
|
db: DbSession,
|
||||||
|
user: CurrentUser,
|
||||||
|
image: Annotated[UploadFile, File()],
|
||||||
|
cloud_type_id: Annotated[int | None, Form()] = None,
|
||||||
|
custom_cloud_type: Annotated[str | None, Form(max_length=80)] = None,
|
||||||
|
latitude: Annotated[float | None, Form(ge=-90, le=90)] = None,
|
||||||
|
longitude: Annotated[float | None, Form(ge=-180, le=180)] = None,
|
||||||
|
location_name: Annotated[str | None, Form(max_length=120)] = None,
|
||||||
|
description: Annotated[str | None, Form(max_length=2000)] = None,
|
||||||
|
captured_at: Annotated[datetime | None, Form()] = None,
|
||||||
|
is_hidden: Annotated[bool, Form()] = False,
|
||||||
|
) -> CloudCreateOut:
|
||||||
|
custom_cloud_type = _normalize_text(custom_cloud_type)
|
||||||
|
if (cloud_type_id is None) == (custom_cloud_type is None):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="必须选择一个云类型,或填写自定义云类型",
|
||||||
|
)
|
||||||
|
if (latitude is None) != (longitude is None):
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="经纬度必须同时填写")
|
||||||
|
|
||||||
|
cloud_type = await _require_cloud_type(db, cloud_type_id) if cloud_type_id else None
|
||||||
|
image_path, thumbnail_path = await save_cloud_image(image, user.id)
|
||||||
|
cloud = Cloud(
|
||||||
|
user_id=user.id,
|
||||||
|
cloud_type_id=cloud_type_id,
|
||||||
|
custom_cloud_type=custom_cloud_type,
|
||||||
|
image_path=image_path,
|
||||||
|
thumbnail_path=thumbnail_path,
|
||||||
|
latitude=_blur_coordinate(latitude),
|
||||||
|
longitude=_blur_coordinate(longitude),
|
||||||
|
location_name=_normalize_text(location_name),
|
||||||
|
description=_normalize_text(description),
|
||||||
|
captured_at=_normalize_datetime(captured_at) or datetime.now(timezone.utc),
|
||||||
|
status="pending",
|
||||||
|
is_hidden=is_hidden,
|
||||||
|
)
|
||||||
|
db.add(cloud)
|
||||||
|
unlocked_badge: BadgeOut | None = None
|
||||||
|
try:
|
||||||
|
await db.flush()
|
||||||
|
if cloud_type:
|
||||||
|
insert_factory = pg_insert if db.bind and db.bind.dialect.name == "postgresql" else sqlite_insert
|
||||||
|
insert_result = await db.execute(
|
||||||
|
insert_factory(UserCollection)
|
||||||
|
.values(
|
||||||
|
user_id=user.id,
|
||||||
|
cloud_type_id=cloud_type.id,
|
||||||
|
first_cloud_id=cloud.id,
|
||||||
|
)
|
||||||
|
.on_conflict_do_nothing(
|
||||||
|
index_elements=[UserCollection.user_id, UserCollection.cloud_type_id]
|
||||||
|
)
|
||||||
|
.returning(UserCollection.unlocked_at)
|
||||||
|
)
|
||||||
|
unlocked_at = insert_result.scalar_one_or_none()
|
||||||
|
if unlocked_at:
|
||||||
|
unlocked_badge = BadgeOut(
|
||||||
|
cloud_type_id=cloud_type.id,
|
||||||
|
cloud_name=cloud_type.name,
|
||||||
|
cloud_name_en=cloud_type.name_en,
|
||||||
|
rarity=cloud_type.rarity,
|
||||||
|
unlocked_at=unlocked_at,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
await db.rollback()
|
||||||
|
await delete_files([image_path, thumbnail_path])
|
||||||
|
raise
|
||||||
|
|
||||||
|
saved = await _get_cloud(db, cloud.id)
|
||||||
|
if not saved:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="图片记录创建失败")
|
||||||
|
return CloudCreateOut(cloud=cloud_out(saved), unlocked_badge=unlocked_badge)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{cloud_id}", response_model=CloudOut)
|
||||||
|
async def update_cloud(
|
||||||
|
cloud_id: uuid.UUID,
|
||||||
|
payload: CloudUpdateIn,
|
||||||
|
db: DbSession,
|
||||||
|
user: CurrentUser,
|
||||||
|
) -> CloudOut:
|
||||||
|
cloud = await _get_cloud(db, cloud_id)
|
||||||
|
if not cloud:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
||||||
|
if cloud.user_id != user.id and user.profile.role != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改其他用户的图片")
|
||||||
|
|
||||||
|
changes = payload.model_dump(exclude_unset=True)
|
||||||
|
final_type_id = changes.get("cloud_type_id", cloud.cloud_type_id)
|
||||||
|
final_custom = _normalize_text(changes.get("custom_cloud_type", cloud.custom_cloud_type))
|
||||||
|
if "cloud_type_id" in changes and changes["cloud_type_id"] is not None and "custom_cloud_type" not in changes:
|
||||||
|
final_custom = None
|
||||||
|
if "custom_cloud_type" in changes and final_custom is not None and "cloud_type_id" not in changes:
|
||||||
|
final_type_id = None
|
||||||
|
if (final_type_id is None) == (final_custom is None):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="必须选择一个云类型,或填写自定义云类型",
|
||||||
|
)
|
||||||
|
if final_type_id is not None:
|
||||||
|
await _require_cloud_type(db, final_type_id)
|
||||||
|
|
||||||
|
if "cloud_type_id" in changes or "custom_cloud_type" in changes:
|
||||||
|
cloud.cloud_type_id = final_type_id
|
||||||
|
cloud.custom_cloud_type = final_custom
|
||||||
|
if "latitude" in changes:
|
||||||
|
cloud.latitude = _blur_coordinate(changes["latitude"])
|
||||||
|
cloud.longitude = _blur_coordinate(changes["longitude"])
|
||||||
|
if "location_name" in changes:
|
||||||
|
cloud.location_name = _normalize_text(changes["location_name"])
|
||||||
|
if "description" in changes:
|
||||||
|
cloud.description = _normalize_text(changes["description"])
|
||||||
|
if "captured_at" in changes:
|
||||||
|
cloud.captured_at = _normalize_datetime(changes["captured_at"])
|
||||||
|
if "is_hidden" in changes:
|
||||||
|
cloud.is_hidden = changes["is_hidden"]
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
updated = await _get_cloud(db, cloud.id)
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
||||||
|
return cloud_out(updated)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{cloud_id}", response_model=DeleteResultOut)
|
||||||
|
async def delete_cloud(cloud_id: uuid.UUID, db: DbSession, user: CurrentUser) -> DeleteResultOut:
|
||||||
|
cloud = await _get_cloud(db, cloud_id)
|
||||||
|
if not cloud:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
||||||
|
if cloud.user_id != user.id and user.profile.role != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能删除其他用户的图片")
|
||||||
|
paths = [cloud.image_path, cloud.thumbnail_path]
|
||||||
|
await db.delete(cloud)
|
||||||
|
await db.commit()
|
||||||
|
await delete_files(paths)
|
||||||
|
return DeleteResultOut(deleted=1)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch-delete", response_model=DeleteResultOut)
|
||||||
|
async def batch_delete_clouds(
|
||||||
|
payload: BatchIdsIn,
|
||||||
|
db: DbSession,
|
||||||
|
user: CurrentUser,
|
||||||
|
) -> DeleteResultOut:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Cloud).where(Cloud.id.in_(payload.ids), Cloud.user_id == user.id)
|
||||||
|
)
|
||||||
|
clouds = result.scalars().all()
|
||||||
|
if len(clouds) != len(payload.ids):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部分图片不存在或不属于当前用户")
|
||||||
|
paths = [path for cloud in clouds for path in (cloud.image_path, cloud.thumbnail_path)]
|
||||||
|
for cloud in clouds:
|
||||||
|
await db.delete(cloud)
|
||||||
|
await db.commit()
|
||||||
|
await delete_files(paths)
|
||||||
|
return DeleteResultOut(deleted=len(clouds))
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
from app.deps import CurrentUser, DbSession
|
||||||
|
from app.models import UserCollection
|
||||||
|
from app.schemas import CollectionCloudOut, CollectionOut
|
||||||
|
from app.serializers import cloud_type_summary, media_url
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/collections", tags=["图鉴"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=list[CollectionOut])
|
||||||
|
async def get_my_collection(user: CurrentUser, db: DbSession) -> list[CollectionOut]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserCollection)
|
||||||
|
.options(joinedload(UserCollection.cloud_type), joinedload(UserCollection.first_cloud))
|
||||||
|
.where(UserCollection.user_id == user.id)
|
||||||
|
.order_by(UserCollection.unlocked_at.asc())
|
||||||
|
)
|
||||||
|
output: list[CollectionOut] = []
|
||||||
|
for item in result.scalars().unique().all():
|
||||||
|
first_cloud = item.first_cloud
|
||||||
|
output.append(
|
||||||
|
CollectionOut(
|
||||||
|
id=item.id,
|
||||||
|
user_id=item.user_id,
|
||||||
|
cloud_type_id=item.cloud_type_id,
|
||||||
|
first_cloud_id=item.first_cloud_id,
|
||||||
|
unlocked_at=item.unlocked_at,
|
||||||
|
cloud_type=cloud_type_summary(item.cloud_type),
|
||||||
|
first_cloud=(
|
||||||
|
CollectionCloudOut(
|
||||||
|
id=first_cloud.id,
|
||||||
|
image_url=media_url(first_cloud.image_path) or "",
|
||||||
|
thumbnail_url=media_url(first_cloud.thumbnail_path) or "",
|
||||||
|
captured_at=first_cloud.captured_at,
|
||||||
|
created_at=first_cloud.created_at,
|
||||||
|
location_name=first_cloud.location_name,
|
||||||
|
)
|
||||||
|
if first_cloud
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return output
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.deps import DbSession
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["系统"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health(db: DbSession) -> dict[str, str]:
|
||||||
|
try:
|
||||||
|
await db.execute(text("select 1"))
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="数据库不可用",
|
||||||
|
) from exc
|
||||||
|
return {"status": "ok", "database": "ok"}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import math
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
from app.deps import CurrentUser, DbSession, OptionalUser
|
||||||
|
from app.models import Cloud, Profile, User
|
||||||
|
from app.schemas import CloudOut, PageOut, ProfileOut, ProfileUpdateIn, PublicProfileOut
|
||||||
|
from app.serializers import cloud_out, profile_out, public_profile_out
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/profiles", tags=["用户资料"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}", response_model=PublicProfileOut)
|
||||||
|
async def get_profile(user_id: uuid.UUID, db: DbSession) -> PublicProfileOut:
|
||||||
|
profile = await db.get(Profile, user_id)
|
||||||
|
if not profile:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
|
return public_profile_out(profile)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me", response_model=ProfileOut)
|
||||||
|
async def update_my_profile(
|
||||||
|
payload: ProfileUpdateIn,
|
||||||
|
user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
) -> ProfileOut:
|
||||||
|
user.profile.username = payload.username
|
||||||
|
try:
|
||||||
|
await db.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="这个昵称已经被使用") from exc
|
||||||
|
return profile_out(user.profile)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}/clouds", response_model=PageOut[CloudOut])
|
||||||
|
async def get_profile_clouds(
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
db: DbSession,
|
||||||
|
viewer: OptionalUser,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(50, ge=1, le=100),
|
||||||
|
) -> PageOut[CloudOut]:
|
||||||
|
if not await db.get(Profile, user_id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
|
|
||||||
|
can_manage = bool(viewer and (viewer.id == user_id or viewer.profile.role == "admin"))
|
||||||
|
filters = [Cloud.user_id == user_id]
|
||||||
|
if not can_manage:
|
||||||
|
filters.extend([Cloud.status == "approved", Cloud.is_hidden.is_(False)])
|
||||||
|
|
||||||
|
total = await db.scalar(select(func.count()).select_from(Cloud).where(*filters)) or 0
|
||||||
|
result = await db.execute(
|
||||||
|
select(Cloud)
|
||||||
|
.options(joinedload(Cloud.user).joinedload(User.profile), joinedload(Cloud.cloud_type))
|
||||||
|
.where(*filters)
|
||||||
|
.order_by(Cloud.captured_at.desc().nulls_last(), Cloud.created_at.desc(), Cloud.id.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
items = [cloud_out(item, include_private=can_manage) for item in result.scalars().unique().all()]
|
||||||
|
return PageOut(
|
||||||
|
items=items,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
total=total,
|
||||||
|
total_pages=max(1, math.ceil(total / page_size)),
|
||||||
|
)
|
||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Generic, Literal, TypeVar
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
Role = Literal["user", "admin"]
|
||||||
|
CloudStatus = Literal["pending", "approved", "rejected"]
|
||||||
|
Rarity = Literal["common", "uncommon", "rare"]
|
||||||
|
|
||||||
|
|
||||||
|
class MessageOut(BaseModel):
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterIn(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(min_length=8, max_length=128)
|
||||||
|
username: str = Field(min_length=2, max_length=32)
|
||||||
|
|
||||||
|
@field_validator("username")
|
||||||
|
@classmethod
|
||||||
|
def strip_username(cls, value: str) -> str:
|
||||||
|
value = value.strip()
|
||||||
|
if len(value) < 2:
|
||||||
|
raise ValueError("昵称至少需要 2 个字符")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class LoginIn(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(min_length=1, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenIn(BaseModel):
|
||||||
|
token: str = Field(min_length=20, max_length=512)
|
||||||
|
|
||||||
|
|
||||||
|
class ForgotPasswordIn(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class ResetPasswordIn(TokenIn):
|
||||||
|
password: str = Field(min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordIn(BaseModel):
|
||||||
|
password: str = Field(min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
username: str
|
||||||
|
avatar_url: str | None = None
|
||||||
|
role: Role
|
||||||
|
is_disabled: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PublicProfileOut(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
username: str
|
||||||
|
avatar_url: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
email: EmailStr
|
||||||
|
email_verified: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AuthOut(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: Literal["bearer"] = "bearer"
|
||||||
|
expires_in: int
|
||||||
|
user: UserOut
|
||||||
|
profile: ProfileOut
|
||||||
|
|
||||||
|
|
||||||
|
class MeOut(BaseModel):
|
||||||
|
user: UserOut
|
||||||
|
profile: ProfileOut
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileUpdateIn(BaseModel):
|
||||||
|
username: str = Field(min_length=2, max_length=32)
|
||||||
|
|
||||||
|
@field_validator("username")
|
||||||
|
@classmethod
|
||||||
|
def strip_username(cls, value: str) -> str:
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class CloudTypeSummary(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
name_en: str
|
||||||
|
rarity: Rarity
|
||||||
|
|
||||||
|
|
||||||
|
class CloudTypeOut(CloudTypeSummary):
|
||||||
|
genus: str
|
||||||
|
description: str | None
|
||||||
|
icon_url: str | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CloudOwnerOut(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
username: str
|
||||||
|
|
||||||
|
|
||||||
|
class CloudOut(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
user_id: uuid.UUID
|
||||||
|
cloud_type_id: int | None
|
||||||
|
custom_cloud_type: str | None
|
||||||
|
image_url: str
|
||||||
|
thumbnail_url: str
|
||||||
|
latitude: float | None
|
||||||
|
longitude: float | None
|
||||||
|
location_name: str | None
|
||||||
|
description: str | None
|
||||||
|
captured_at: datetime | None
|
||||||
|
status: CloudStatus
|
||||||
|
is_hidden: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
cloud_type: CloudTypeSummary | None
|
||||||
|
owner: CloudOwnerOut | None
|
||||||
|
cloud_type_name: str
|
||||||
|
cloud_type_rarity: Rarity
|
||||||
|
username: str
|
||||||
|
|
||||||
|
|
||||||
|
class CloudUpdateIn(BaseModel):
|
||||||
|
cloud_type_id: int | None = None
|
||||||
|
custom_cloud_type: str | None = Field(default=None, max_length=80)
|
||||||
|
latitude: float | None = Field(default=None, ge=-90, le=90)
|
||||||
|
longitude: float | None = Field(default=None, ge=-180, le=180)
|
||||||
|
location_name: str | None = Field(default=None, max_length=120)
|
||||||
|
description: str | None = Field(default=None, max_length=2000)
|
||||||
|
captured_at: datetime | None = None
|
||||||
|
is_hidden: bool | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_coordinates(self) -> "CloudUpdateIn":
|
||||||
|
provided = self.model_fields_set
|
||||||
|
if ("latitude" in provided) != ("longitude" in provided):
|
||||||
|
raise ValueError("经纬度必须同时提供")
|
||||||
|
if "latitude" in provided and ((self.latitude is None) != (self.longitude is None)):
|
||||||
|
raise ValueError("经纬度必须同时为空或同时有值")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class BadgeOut(BaseModel):
|
||||||
|
cloud_type_id: int
|
||||||
|
cloud_name: str
|
||||||
|
cloud_name_en: str
|
||||||
|
rarity: Rarity
|
||||||
|
unlocked_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CloudCreateOut(BaseModel):
|
||||||
|
cloud: CloudOut
|
||||||
|
unlocked_badge: BadgeOut | None
|
||||||
|
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class PageOut(BaseModel, Generic[T]):
|
||||||
|
items: list[T]
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
total: int
|
||||||
|
total_pages: int
|
||||||
|
|
||||||
|
|
||||||
|
class BatchIdsIn(BaseModel):
|
||||||
|
ids: list[uuid.UUID] = Field(min_length=1, max_length=100)
|
||||||
|
|
||||||
|
@field_validator("ids")
|
||||||
|
@classmethod
|
||||||
|
def unique_ids(cls, value: list[uuid.UUID]) -> list[uuid.UUID]:
|
||||||
|
return list(dict.fromkeys(value))
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteResultOut(BaseModel):
|
||||||
|
deleted: int
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionCloudOut(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
image_url: str
|
||||||
|
thumbnail_url: str
|
||||||
|
captured_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
location_name: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionOut(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
user_id: uuid.UUID
|
||||||
|
cloud_type_id: int
|
||||||
|
first_cloud_id: uuid.UUID | None
|
||||||
|
unlocked_at: datetime
|
||||||
|
cloud_type: CloudTypeSummary
|
||||||
|
first_cloud: CollectionCloudOut | None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminStatsOut(BaseModel):
|
||||||
|
users: int
|
||||||
|
images: int
|
||||||
|
today_uploads: int
|
||||||
|
pending: int
|
||||||
|
approved: int
|
||||||
|
rejected: int
|
||||||
|
hidden: int
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserOut(BaseModel):
|
||||||
|
user: UserOut
|
||||||
|
profile: ProfileOut
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserUpdateIn(BaseModel):
|
||||||
|
role: Role | None = None
|
||||||
|
is_disabled: bool | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def require_change(self) -> "AdminUserUpdateIn":
|
||||||
|
if not self.model_fields_set:
|
||||||
|
raise ValueError("至少需要提供一个修改字段")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class AdminCloudStatusIn(BatchIdsIn):
|
||||||
|
status: CloudStatus
|
||||||
|
|
||||||
|
|
||||||
|
class AdminCloudVisibilityIn(BatchIdsIn):
|
||||||
|
is_hidden: bool
|
||||||
|
|
||||||
|
|
||||||
|
class BatchUpdateOut(BaseModel):
|
||||||
|
updated: int
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from jwt.exceptions import InvalidTokenError
|
||||||
|
from pwdlib import PasswordHash
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
password_hash = PasswordHash.recommended()
|
||||||
|
DUMMY_PASSWORD_HASH = password_hash.hash("opencloud-dummy-password")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AccessTokenPayload:
|
||||||
|
user_id: uuid.UUID
|
||||||
|
session_id: uuid.UUID
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return password_hash.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, encoded_hash: str) -> bool:
|
||||||
|
return password_hash.verify(password, encoded_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def create_random_token() -> str:
|
||||||
|
return secrets.token_urlsafe(48)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(user_id: uuid.UUID, session_id: uuid.UUID) -> str:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
payload = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"sid": str(session_id),
|
||||||
|
"type": "access",
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(minutes=settings.access_token_expire_minutes),
|
||||||
|
"jti": str(uuid.uuid4()),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_access_token(token: str) -> AccessTokenPayload:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.jwt_algorithm])
|
||||||
|
if payload.get("type") != "access":
|
||||||
|
raise InvalidTokenError("wrong token type")
|
||||||
|
return AccessTokenPayload(
|
||||||
|
user_id=uuid.UUID(payload["sub"]),
|
||||||
|
session_id=uuid.UUID(payload["sid"]),
|
||||||
|
)
|
||||||
|
except (InvalidTokenError, KeyError, TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("无效或已过期的访问令牌") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_expires_at() -> datetime:
|
||||||
|
return utc_now() + timedelta(days=settings.refresh_token_expire_days)
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models import CloudType, Profile, User
|
||||||
|
from app.security import hash_password, utc_now
|
||||||
|
|
||||||
|
|
||||||
|
CLOUD_TYPES = [
|
||||||
|
{"id": 1, "name": "积云", "name_en": "Cumulus", "genus": "低云族", "rarity": "common", "description": "轮廓分明、底部较平,常呈棉花团状的对流云。"},
|
||||||
|
{"id": 2, "name": "层云", "name_en": "Stratus", "genus": "低云族", "rarity": "common", "description": "低而均匀的灰色云层,常覆盖大部分天空。"},
|
||||||
|
{"id": 3, "name": "卷云", "name_en": "Cirrus", "genus": "高云族", "rarity": "common", "description": "由冰晶组成的纤细白色云丝,常呈羽毛或马尾状。"},
|
||||||
|
{"id": 4, "name": "积雨云", "name_en": "Cumulonimbus", "genus": "直展云族", "rarity": "uncommon", "description": "垂直发展旺盛的巨大云体,可能带来雷雨、冰雹和强风。"},
|
||||||
|
{"id": 5, "name": "层积云", "name_en": "Stratocumulus", "genus": "低云族", "rarity": "common", "description": "成片或成层排列的低云块,常带有明暗相间的纹理。"},
|
||||||
|
{"id": 6, "name": "高积云", "name_en": "Altocumulus", "genus": "中云族", "rarity": "common", "description": "由较小云块组成的中层云,常呈波纹或鱼鳞状。"},
|
||||||
|
{"id": 7, "name": "高层云", "name_en": "Altostratus", "genus": "中云族", "rarity": "uncommon", "description": "灰蓝色的中层云幕,太阳通常像隔着毛玻璃一样可见。"},
|
||||||
|
{"id": 8, "name": "雨层云", "name_en": "Nimbostratus", "genus": "中低云族", "rarity": "uncommon", "description": "厚而暗的降水云层,常带来持续性的雨或雪。"},
|
||||||
|
{"id": 9, "name": "卷层云", "name_en": "Cirrostratus", "genus": "高云族", "rarity": "rare", "description": "薄而透明的冰晶云幕,常在日月周围形成光环。"},
|
||||||
|
{"id": 10, "name": "卷积云", "name_en": "Cirrocumulus", "genus": "高云族", "rarity": "rare", "description": "细小白色云粒规则排列形成的高云,俗称鱼鳞天。"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def seed() -> None:
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
for values in CLOUD_TYPES:
|
||||||
|
cloud_type = await db.get(CloudType, values["id"])
|
||||||
|
if cloud_type:
|
||||||
|
for key, value in values.items():
|
||||||
|
setattr(cloud_type, key, value)
|
||||||
|
else:
|
||||||
|
db.add(CloudType(**values))
|
||||||
|
|
||||||
|
if settings.admin_email and settings.admin_password:
|
||||||
|
email = settings.admin_email.strip().lower()
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).options(selectinload(User.profile)).where(User.email == email)
|
||||||
|
)
|
||||||
|
admin = result.scalar_one_or_none()
|
||||||
|
if admin:
|
||||||
|
admin.profile.role = "admin"
|
||||||
|
admin.profile.is_disabled = False
|
||||||
|
admin.email_verified_at = admin.email_verified_at or utc_now()
|
||||||
|
else:
|
||||||
|
admin = User(
|
||||||
|
email=email,
|
||||||
|
password_hash=hash_password(settings.admin_password),
|
||||||
|
email_verified_at=utc_now(),
|
||||||
|
)
|
||||||
|
admin.profile = Profile(username=settings.admin_username, role="admin")
|
||||||
|
db.add(admin)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
asyncio.run(seed())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
from app.config import settings
|
||||||
|
from app.models import Cloud, CloudType, Profile, User
|
||||||
|
from app.schemas import (
|
||||||
|
CloudOut,
|
||||||
|
CloudOwnerOut,
|
||||||
|
CloudTypeOut,
|
||||||
|
CloudTypeSummary,
|
||||||
|
ProfileOut,
|
||||||
|
PublicProfileOut,
|
||||||
|
UserOut,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def media_url(path: str | None) -> str | None:
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
return f"{settings.media_base_url}/{path.lstrip('/')}"
|
||||||
|
|
||||||
|
|
||||||
|
def profile_out(profile: Profile) -> ProfileOut:
|
||||||
|
return ProfileOut(
|
||||||
|
id=profile.id,
|
||||||
|
username=profile.username,
|
||||||
|
avatar_url=media_url(profile.avatar_path),
|
||||||
|
role=profile.role,
|
||||||
|
is_disabled=profile.is_disabled,
|
||||||
|
created_at=profile.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def public_profile_out(profile: Profile) -> PublicProfileOut:
|
||||||
|
return PublicProfileOut(
|
||||||
|
id=profile.id,
|
||||||
|
username=profile.username,
|
||||||
|
avatar_url=media_url(profile.avatar_path),
|
||||||
|
created_at=profile.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def user_out(user: User) -> UserOut:
|
||||||
|
return UserOut(
|
||||||
|
id=user.id,
|
||||||
|
email=user.email,
|
||||||
|
email_verified=user.email_verified_at is not None,
|
||||||
|
created_at=user.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cloud_type_summary(cloud_type: CloudType) -> CloudTypeSummary:
|
||||||
|
return CloudTypeSummary(
|
||||||
|
id=cloud_type.id,
|
||||||
|
name=cloud_type.name,
|
||||||
|
name_en=cloud_type.name_en,
|
||||||
|
rarity=cloud_type.rarity,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cloud_type_out(cloud_type: CloudType) -> CloudTypeOut:
|
||||||
|
return CloudTypeOut(
|
||||||
|
id=cloud_type.id,
|
||||||
|
name=cloud_type.name,
|
||||||
|
name_en=cloud_type.name_en,
|
||||||
|
genus=cloud_type.genus,
|
||||||
|
rarity=cloud_type.rarity,
|
||||||
|
description=cloud_type.description,
|
||||||
|
icon_url=cloud_type.icon_url,
|
||||||
|
created_at=cloud_type.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cloud_out(cloud: Cloud, *, include_private: bool = True) -> CloudOut:
|
||||||
|
cloud_type = cloud.cloud_type
|
||||||
|
profile = cloud.user.profile
|
||||||
|
type_name = cloud_type.name if cloud_type else (cloud.custom_cloud_type or "未知")
|
||||||
|
rarity = cloud_type.rarity if cloud_type else "common"
|
||||||
|
return CloudOut(
|
||||||
|
id=cloud.id,
|
||||||
|
user_id=cloud.user_id,
|
||||||
|
cloud_type_id=cloud.cloud_type_id,
|
||||||
|
custom_cloud_type=cloud.custom_cloud_type,
|
||||||
|
image_url=media_url(cloud.image_path) or "",
|
||||||
|
thumbnail_url=media_url(cloud.thumbnail_path) or "",
|
||||||
|
latitude=float(cloud.latitude) if cloud.latitude is not None else None,
|
||||||
|
longitude=float(cloud.longitude) if cloud.longitude is not None else None,
|
||||||
|
location_name=cloud.location_name,
|
||||||
|
description=cloud.description,
|
||||||
|
captured_at=cloud.captured_at,
|
||||||
|
status=cloud.status if include_private else "approved",
|
||||||
|
is_hidden=cloud.is_hidden if include_private else False,
|
||||||
|
created_at=cloud.created_at,
|
||||||
|
updated_at=cloud.updated_at,
|
||||||
|
cloud_type=cloud_type_summary(cloud_type) if cloud_type else None,
|
||||||
|
owner=CloudOwnerOut(id=profile.id, username=profile.username),
|
||||||
|
cloud_type_name=type_name,
|
||||||
|
cloud_type_rarity=rarity,
|
||||||
|
username=profile.username,
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Application services."""
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import logging
|
||||||
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
import aiosmtplib
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _send(subject: str, recipient: str, text: str) -> None:
|
||||||
|
if settings.email_delivery_mode == "console":
|
||||||
|
logger.warning("开发邮件\nTo: %s\nSubject: %s\n%s", recipient, subject, text)
|
||||||
|
return
|
||||||
|
|
||||||
|
message = EmailMessage()
|
||||||
|
message["From"] = settings.email_from
|
||||||
|
message["To"] = recipient
|
||||||
|
message["Subject"] = subject
|
||||||
|
message.set_content(text)
|
||||||
|
|
||||||
|
await aiosmtplib.send(
|
||||||
|
message,
|
||||||
|
hostname=settings.smtp_host,
|
||||||
|
port=settings.smtp_port,
|
||||||
|
username=settings.smtp_username,
|
||||||
|
password=settings.smtp_password,
|
||||||
|
start_tls=settings.smtp_starttls,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_confirmation_email(recipient: str, token: str) -> None:
|
||||||
|
url = f"{settings.frontend_url.rstrip('/')}/auth/confirm?token={token}"
|
||||||
|
await _send(
|
||||||
|
"确认你的 OpenCloud 邮箱",
|
||||||
|
recipient,
|
||||||
|
f"请打开下面的链接完成邮箱确认:\n\n{url}\n\n链接将在指定时间后失效。",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_password_reset_email(recipient: str, token: str) -> None:
|
||||||
|
url = f"{settings.frontend_url.rstrip('/')}/auth/reset-password?token={token}"
|
||||||
|
await _send(
|
||||||
|
"重置你的 OpenCloud 密码",
|
||||||
|
recipient,
|
||||||
|
f"请打开下面的链接重置密码:\n\n{url}\n\n如果不是你发起的请求,请忽略此邮件。",
|
||||||
|
)
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import HTTPException, UploadFile, status
|
||||||
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
ALLOWED_FORMATS = {"JPEG": "jpg", "PNG": "png", "WEBP": "webp"}
|
||||||
|
|
||||||
|
|
||||||
|
def _process_image(raw: bytes) -> tuple[bytes, str, bytes]:
|
||||||
|
Image.MAX_IMAGE_PIXELS = settings.max_image_pixels
|
||||||
|
try:
|
||||||
|
with Image.open(io.BytesIO(raw)) as source:
|
||||||
|
if source.width * source.height > settings.max_image_pixels:
|
||||||
|
raise ValueError("图片像素尺寸过大")
|
||||||
|
source.load()
|
||||||
|
image_format = source.format
|
||||||
|
if image_format not in ALLOWED_FORMATS:
|
||||||
|
raise ValueError("仅支持 JPEG、PNG 和 WebP 图片")
|
||||||
|
|
||||||
|
extension = ALLOWED_FORMATS[image_format]
|
||||||
|
normalized = io.BytesIO()
|
||||||
|
if image_format == "PNG":
|
||||||
|
source.save(normalized, format="PNG", optimize=True)
|
||||||
|
else:
|
||||||
|
rgb = source.convert("RGB")
|
||||||
|
output_format = "JPEG" if image_format == "JPEG" else "WEBP"
|
||||||
|
rgb.save(normalized, format=output_format, quality=92, optimize=True)
|
||||||
|
|
||||||
|
thumbnail = source.copy()
|
||||||
|
thumbnail.thumbnail(
|
||||||
|
(settings.thumbnail_max_edge, settings.thumbnail_max_edge),
|
||||||
|
Image.Resampling.LANCZOS,
|
||||||
|
)
|
||||||
|
thumbnail_bytes = io.BytesIO()
|
||||||
|
thumbnail.convert("RGB").save(
|
||||||
|
thumbnail_bytes,
|
||||||
|
format="JPEG",
|
||||||
|
quality=78,
|
||||||
|
optimize=True,
|
||||||
|
)
|
||||||
|
return normalized.getvalue(), extension, thumbnail_bytes.getvalue()
|
||||||
|
except (UnidentifiedImageError, OSError, Image.DecompressionBombError, ValueError) as exc:
|
||||||
|
raise ValueError(str(exc) or "图片内容无效") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _write_files(
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
original: bytes,
|
||||||
|
extension: str,
|
||||||
|
thumbnail: bytes,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
file_id = uuid.uuid4()
|
||||||
|
relative_dir = Path(str(user_id))
|
||||||
|
image_path = relative_dir / f"{file_id}.{extension}"
|
||||||
|
thumbnail_path = relative_dir / f"{file_id}-thumb.jpg"
|
||||||
|
target_dir = settings.upload_dir / relative_dir
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(settings.upload_dir / image_path).write_bytes(original)
|
||||||
|
(settings.upload_dir / thumbnail_path).write_bytes(thumbnail)
|
||||||
|
return image_path.as_posix(), thumbnail_path.as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
async def save_cloud_image(file: UploadFile, user_id: uuid.UUID) -> tuple[str, str]:
|
||||||
|
raw = await file.read(settings.max_upload_bytes + 1)
|
||||||
|
if not raw:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="图片不能为空")
|
||||||
|
if len(raw) > settings.max_upload_bytes:
|
||||||
|
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="图片文件过大")
|
||||||
|
try:
|
||||||
|
original, extension, thumbnail = await asyncio.to_thread(_process_image, raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"图片无法处理:{exc}",
|
||||||
|
) from exc
|
||||||
|
return await asyncio.to_thread(_write_files, user_id, original, extension, thumbnail)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_file_path(relative_path: str) -> Path | None:
|
||||||
|
root = settings.upload_dir.resolve()
|
||||||
|
candidate = (root / relative_path).resolve()
|
||||||
|
if candidate == root or root not in candidate.parents:
|
||||||
|
return None
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_files(paths: list[str]) -> None:
|
||||||
|
def remove() -> None:
|
||||||
|
for relative_path in paths:
|
||||||
|
candidate = _safe_file_path(relative_path)
|
||||||
|
if not candidate:
|
||||||
|
logger.error("拒绝删除不安全的文件路径:%s", relative_path)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
candidate.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
logger.exception("删除图片失败:%s", relative_path)
|
||||||
|
|
||||||
|
await asyncio.to_thread(remove)
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user