Stage 1: project scaffold with unified middleware and test DB

This commit is contained in:
2026-02-26 19:51:14 +08:00
commit 3177678373
31 changed files with 1032 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
# ============================================
# .env.example — 所有环境变量模板
# ============================================
# ── 数据库 ──
DATABASE_URL=postgresql+asyncpg://wrongbook:wrongbook@localhost:45432/wrongbook
TEST_DATABASE_URL=postgresql+asyncpg://wrongbook:wrongbook@localhost:45433/wrongbook_test
# ── Redis ──
REDIS_URL=redis://localhost:46379/0
# ── MinIO 对象存储 ──
MINIO_ENDPOINT=localhost:49000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=wrongbook
MINIO_USE_SSL=false
MINIO_PRESIGN_EXPIRY=3600
# ── AI 服务 (可选, 为空则使用 mock) ──
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-sonnet-4-20250514
ANTHROPIC_TEMPERATURE=0.1
ANTHROPIC_MAX_TOKENS_RECOGNIZE=2048
ANTHROPIC_MAX_TOKENS_ANALYZE=4096
# ── 手写清除 (可选, 为空则使用本地OpenCV降级) ──
REMOVE_HW_API_KEY=
REMOVE_HW_API_URL=https://removehandwriting.com/api/v1/process
CLEAN_STRATEGY=local
# ── PaddleOCR (可选, false 时降级为规则切分) ──
ENABLE_PADDLE_OCR=false
# ── JWT ──
JWT_SECRET=your-secret-key-change-in-production
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=10080
# ── 应用 ──
APP_ENV=development
LOG_LEVEL=INFO
+87
View File
@@ -0,0 +1,87 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
.pytest_cache/
.coverage
htmlcov/
.tox/
.hypothesis/
*.log
# Virtual Environment
venv/
env/
ENV/
.venv
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Environment
.env
.env.local
.env.*.local
# Flutter
app/.dart_tool/
app/.flutter-plugins
app/.flutter-plugins-dependencies
app/.packages
app/build/
app/ios/Flutter/Generated.xcconfig
app/ios/Flutter/flutter_export_environment.sh
app/ios/Pods/
app/ios/.symlinks/
app/android/.gradle/
app/android/captures/
app/android/local.properties
app/android/app/debug/
app/android/app/profile/
app/android/app/release/
app/pubspec.lock
# Node / uni-app
miniprogram/node_modules/
miniprogram/dist/
miniprogram/unpackage/
miniprogram/.hbuilderx/
miniprogram/package-lock.json
# Docker
*.log
docker-compose.override.yml
# Database
*.db
*.sqlite
*.sqlite3
# MinIO data
minio-data/
# Alembic
backend/alembic/versions/*.pyc
+45
View File
@@ -0,0 +1,45 @@
# CLAUDE.md — 智能错题本项目 Agent 指令 (v2)
## 项目信息
- 项目名称: WrongBook (智能错题本)
- 仓库结构: monorepo
- `backend/` — Python FastAPI 后端
- `app/` — Flutter 客户端 (MVP: Android + iOS;鸿蒙为占位适配层)
- `miniprogram/` — uni-app 微信小程序轻量版
- `infra/` — Docker / 部署配置
- `docs/` — 设计文档
## 技术栈 (严格遵守)
- 后端: Python 3.11, FastAPI, SQLAlchemy 2.0 (async), PostgreSQL 16, Redis 7, Celery
- 客户端: Flutter 3.22+, Dart 3.4+, Riverpod, flutter_math_fork, Dio
- 小程序: uni-app (Vue3 + TypeScript)
- AI: Claude Sonnet API (vision), PaddleOCR (可选,缺失时降级规则切分)
- 全文检索: PostgreSQL tsvector (不使用 Elasticsearch)
- 部署: Docker Compose
## 代码规范
- Python: ruff 格式化, type hints 必须写, docstring 中文
- Dart: flutter_lints, 优先 const 构造函数
- 所有 API 返回统一格式: {"code": 0, "data": {}, "message": ""}
- 错误也走统一格式: {"code": 错误码, "data": null, "message": "描述"}
- 数据库迁移: Alembic
- 环境变量: .env → pydantic Settings
## 构建/测试命令
- 后端测试: `cd backend && pytest -x -v` (需要 Docker 中的 PostgreSQL 可用)
- 后端启动: `cd backend && uvicorn app.main:app --reload`
- 后端 lint: `cd backend && ruff check .`
- Flutter 构建: `cd app && flutter build apk --debug`
- Flutter 分析: `cd app && flutter analyze`
## IMPORTANT 约束 (违反任何一条视为 Bug)
- YOU MUST: 每个文件写完后运行 ruff check (Python) 或 flutter analyze (Dart)
- YOU MUST: 数据库操作一律 async, 禁止同步 ORM
- YOU MUST: Celery task 内调用 async 代码时使用 asyncio.run()
- YOU MUST: 所有外部 API 调用 (Anthropic/RemoveHandwriting) 必须通过接口层+依赖注入, 没有 API key 时测试不能报错
- YOU MUST: 图片存储只存 object_key, 读取时生成 presigned URL, 禁止存裸公开URL
- YOU MUST: WebSocket 连接必须用 JWT 鉴权, 禁止在路径中暴露 user_id
- YOU MUST: Flutter 中平台相关代码必须通过 PlatformService 抽象层
- YOU MUST: AI prompt 模板放在 backend/app/prompts/ 目录, 不硬编码
- YOU MUST: 每完成一个模块立即写单元测试
- YOU MUST: 所有日志使用 structlog, 每条日志带 request_id
View File
+112
View File
@@ -0,0 +1,112 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = postgresql+asyncpg://wrongbook:wrongbook@localhost:5432/wrongbook
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[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
+96
View File
@@ -0,0 +1,96 @@
"""Alembic 环境配置 - 支持异步迁移"""
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# 导入配置和模型
from app.config import settings
from app.models.base import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# 从环境变量覆盖数据库URL
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""In this scenario we need to create an Engine
and associate a connection with the context.
"""
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()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
View File
View File
+10
View File
@@ -0,0 +1,10 @@
"""API 路由汇总"""
from fastapi import APIRouter
api_router = APIRouter()
# TODO: 在后续阶段添加各个模块的路由
# api_router.include_router(auth.router, prefix="/auth", tags=["认证"])
# api_router.include_router(users.router, prefix="/users", tags=["用户"])
# api_router.include_router(subjects.router, prefix="/subjects", tags=["学科"])
+53
View File
@@ -0,0 +1,53 @@
"""配置管理模块"""
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""应用配置"""
# 数据库
DATABASE_URL: str = "postgresql+asyncpg://wrongbook:wrongbook@localhost:45432/wrongbook"
TEST_DATABASE_URL: str = (
"postgresql+asyncpg://wrongbook:wrongbook@localhost:45433/wrongbook_test"
)
# Redis
REDIS_URL: str = "redis://localhost:46379/0"
# MinIO
MINIO_ENDPOINT: str = "localhost:49000"
MINIO_ACCESS_KEY: str = "minioadmin"
MINIO_SECRET_KEY: str = "minioadmin"
MINIO_BUCKET: str = "wrongbook"
MINIO_USE_SSL: bool = False
MINIO_PRESIGN_EXPIRY: int = 3600 # presigned URL 有效期(秒)
# AI (可选)
ANTHROPIC_API_KEY: str = "" # 为空=不可用, 测试用mock
ANTHROPIC_MODEL: str = "claude-sonnet-4-20250514"
ANTHROPIC_TEMPERATURE: float = 0.1
ANTHROPIC_MAX_TOKENS_RECOGNIZE: int = 2048
ANTHROPIC_MAX_TOKENS_ANALYZE: int = 4096
# 手写清除
REMOVE_HW_API_KEY: str = ""
REMOVE_HW_API_URL: str = "https://removehandwriting.com/api/v1/process"
CLEAN_STRATEGY: str = "local" # "local" | "api" | "hybrid"
# PaddleOCR
ENABLE_PADDLE_OCR: bool = False # True 时才尝试导入
# JWT
JWT_SECRET: str = "dev-secret-change-in-prod"
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRE_MINUTES: int = 10080
# App
APP_ENV: str = "development"
LOG_LEVEL: str = "INFO"
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
settings = Settings()
+38
View File
@@ -0,0 +1,38 @@
"""数据库连接管理"""
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.config import settings
# 创建异步引擎
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.APP_ENV == "development",
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
)
# 创建会话工厂
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""获取数据库会话依赖"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
+15
View File
@@ -0,0 +1,15 @@
"""通用依赖注入"""
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.redis_client import get_redis
from app.storage import StorageClient, get_storage
# 类型别名
DbSession = Annotated[AsyncSession, Depends(get_db)]
RedisClient = Annotated[object, Depends(get_redis)]
Storage = Annotated[StorageClient, Depends(get_storage)]
+47
View File
@@ -0,0 +1,47 @@
"""日志配置 - structlog"""
import logging
import sys
import structlog
from app.config import settings
def configure_logging():
"""配置 structlog"""
# 配置标准库 logging
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=getattr(logging, settings.LOG_LEVEL.upper()),
)
# 配置 structlog
processors = [
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
]
if settings.APP_ENV == "development":
# 开发环境: 彩色输出
processors.append(structlog.dev.ConsoleRenderer())
else:
# 生产环境: JSON 输出
processors.append(structlog.processors.JSONRenderer())
structlog.configure(
processors=processors,
wrapper_class=structlog.make_filtering_bound_logger(
getattr(logging, settings.LOG_LEVEL.upper())
),
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)
# 初始化日志
configure_logging()
+60
View File
@@ -0,0 +1,60 @@
"""主应用入口"""
from contextlib import asynccontextmanager
import structlog
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app import logger # noqa: F401 - 初始化日志
from app.api.router import api_router
from app.middleware import RequestIdMiddleware, register_exception_handlers
log = structlog.get_logger()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
log.info("application_startup")
# TODO: 在这里添加种子数据初始化
yield
log.info("application_shutdown")
app = FastAPI(
title="智能错题本 API",
description="WrongBook Backend API",
version="0.1.0",
lifespan=lifespan,
)
# 添加 CORS 中间件
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 生产环境应该限制具体域名
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 添加 RequestId 中间件
app.add_middleware(RequestIdMiddleware)
# 注册异常处理器
register_exception_handlers(app)
# 注册路由
app.include_router(api_router, prefix="/api/v1")
@app.get("/")
async def root():
"""健康检查"""
return {"status": "ok", "service": "wrongbook-api"}
@app.get("/health")
async def health():
"""健康检查"""
return {"status": "healthy"}
+59
View File
@@ -0,0 +1,59 @@
"""中间件模块 - 统一请求处理"""
import uuid
from typing import Callable
import structlog
from fastapi import FastAPI, Request, Response, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
logger = structlog.get_logger()
class RequestIdMiddleware(BaseHTTPMiddleware):
"""为每个请求生成唯一 request_id"""
async def dispatch(self, request: Request, call_next: Callable) -> Response:
request_id = str(uuid.uuid4())
# 绑定到 structlog context
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(request_id=request_id)
# 添加到请求状态
request.state.request_id = request_id
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
def register_exception_handlers(app: FastAPI) -> None:
"""注册全局异常处理器"""
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""处理请求验证错误"""
logger.warning("validation_error", errors=exc.errors())
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"code": 422,
"data": None,
"message": f"请求参数验证失败: {exc.errors()[0]['msg']}",
},
)
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
"""处理未捕获的异常"""
logger.error("unhandled_exception", exc_info=exc)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"code": 500,
"data": None,
"message": "服务器内部错误",
},
)
View File
+27
View File
@@ -0,0 +1,27 @@
"""数据库模型基类"""
from datetime import datetime
from sqlalchemy import BigInteger, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
"""ORM 基类"""
pass
class IdMixin:
"""ID Mixin"""
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
class TimestampMixin:
"""时间戳 Mixin"""
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
server_default=func.now(), onupdate=func.now(), nullable=False
)
View File
+18
View File
@@ -0,0 +1,18 @@
"""Redis 客户端"""
import redis.asyncio as redis
from app.config import settings
# 创建 Redis 连接池
redis_client = redis.from_url(
settings.REDIS_URL,
encoding="utf-8",
decode_responses=True,
max_connections=50,
)
async def get_redis() -> redis.Redis:
"""获取 Redis 客户端依赖"""
return redis_client
View File
+25
View File
@@ -0,0 +1,25 @@
"""统一响应模型"""
from typing import Generic, TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class ApiResponse(BaseModel, Generic[T]):
"""统一 API 响应格式"""
code: int = 0
data: T | None = None
message: str = "success"
def success(data=None, message: str = "success") -> dict:
"""成功响应"""
return {"code": 0, "data": data, "message": message}
def error(code: int, message: str) -> dict:
"""错误响应"""
return {"code": code, "data": None, "message": message}
View File
+100
View File
@@ -0,0 +1,100 @@
"""MinIO 对象存储封装"""
from datetime import timedelta
from io import BytesIO
from minio import Minio
from minio.error import S3Error
from app.config import settings
class StorageClient:
"""对象存储客户端"""
def __init__(self):
self.client = Minio(
settings.MINIO_ENDPOINT,
access_key=settings.MINIO_ACCESS_KEY,
secret_key=settings.MINIO_SECRET_KEY,
secure=settings.MINIO_USE_SSL,
)
self.bucket = settings.MINIO_BUCKET
self._ensure_bucket()
def _ensure_bucket(self):
"""确保 bucket 存在"""
try:
if not self.client.bucket_exists(self.bucket):
self.client.make_bucket(self.bucket)
except S3Error as e:
print(f"Error creating bucket: {e}")
def upload(self, object_key: str, data: bytes) -> str:
"""
上传文件到对象存储
Args:
object_key: 对象键名 (例如: "papers/2024/01/abc123.jpg")
data: 文件字节数据
Returns:
object_key: 返回对象键名 (不是URL!)
"""
try:
self.client.put_object(
self.bucket,
object_key,
BytesIO(data),
length=len(data),
)
return object_key
except S3Error as e:
raise RuntimeError(f"Upload failed: {e}")
def get_presigned_url(self, object_key: str) -> str:
"""
生成预签名URL
Args:
object_key: 对象键名
Returns:
presigned_url: 带签名的临时访问URL
"""
try:
url = self.client.presigned_get_object(
self.bucket,
object_key,
expires=timedelta(seconds=settings.MINIO_PRESIGN_EXPIRY),
)
return url
except S3Error as e:
raise RuntimeError(f"Get presigned URL failed: {e}")
def delete(self, object_key: str) -> None:
"""删除对象"""
try:
self.client.remove_object(self.bucket, object_key)
except S3Error as e:
raise RuntimeError(f"Delete failed: {e}")
def get_object(self, object_key: str) -> bytes:
"""下载对象"""
try:
response = self.client.get_object(self.bucket, object_key)
data = response.read()
response.close()
response.release_conn()
return data
except S3Error as e:
raise RuntimeError(f"Download failed: {e}")
# 全局实例
storage_client = StorageClient()
def get_storage() -> StorageClient:
"""获取存储客户端依赖"""
return storage_client
View File
+60
View File
@@ -0,0 +1,60 @@
[project]
name = "wrongbook-backend"
version = "0.1.0"
requires-python = ">=3.11,<3.13"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
"sqlalchemy[asyncio]>=2.0.30",
"asyncpg>=0.29.0",
"alembic>=1.13.0",
"redis>=5.0.0",
"celery[redis]>=5.4.0",
"httpx>=0.27.0",
"pydantic>=2.7.0",
"pydantic-settings>=2.3.0",
"python-multipart>=0.0.9",
"python-jose[cryptography]>=3.3.0",
"Pillow>=10.3.0",
"opencv-python-headless>=4.9.0",
"numpy>=1.26.0",
"structlog>=24.1.0",
"minio>=7.2.0",
]
[project.optional-dependencies]
paddle = [
"paddlepaddle>=2.6.0",
"paddleocr>=2.7.0",
]
dev = [
"pytest>=8.2.0",
"pytest-asyncio>=0.23.0",
"httpx>=0.27.0",
"ruff>=0.4.0",
"testcontainers[postgres]>=4.4.0",
]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["."]
include = ["app*"]
exclude = ["tests*", "alembic*"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W"]
ignore = ["E501"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
View File
+81
View File
@@ -0,0 +1,81 @@
"""测试配置和 fixtures"""
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.config import settings
from app.database import get_db
from app.main import app
from app.models.base import Base
# 测试数据库引擎
@pytest.fixture(scope="session")
def test_engine():
"""创建测试数据库引擎"""
engine = create_async_engine(
settings.TEST_DATABASE_URL,
echo=False,
pool_pre_ping=True,
)
return engine
@pytest_asyncio.fixture(scope="session")
async def setup_database(test_engine):
"""设置测试数据库 - 创建所有表"""
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest_asyncio.fixture
async def db_session(test_engine, setup_database):
"""提供数据库会话 - 每个测试用事务包裹并回滚"""
async_session = async_sessionmaker(
test_engine, class_=AsyncSession, expire_on_commit=False
)
async with async_session() as session:
async with session.begin():
yield session
await session.rollback()
@pytest_asyncio.fixture
async def client(db_session):
"""提供测试客户端"""
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
yield ac
app.dependency_overrides.clear()
@pytest.fixture
def mock_ai_client():
"""Mock AI 客户端 - 用于测试"""
class MockAiClient:
def __init__(self):
self.is_available = False
async def call_vision(self, **kwargs):
return {
"text": '{"question_text": "测试题目", "confidence": {"overall": 0.9}}',
"input_tokens": 100,
"output_tokens": 50,
}
return MockAiClient()
+54
View File
@@ -0,0 +1,54 @@
services:
postgres:
image: postgres:16-alpine
ports:
- "45432:5432"
environment:
POSTGRES_DB: wrongbook
POSTGRES_USER: wrongbook
POSTGRES_PASSWORD: wrongbook
volumes:
- pgdata:/var/lib/postgresql/data
- ./infra/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U wrongbook"]
interval: 5s
timeout: 5s
retries: 5
postgres-test:
image: postgres:16-alpine
ports:
- "45433:5432"
environment:
POSTGRES_DB: wrongbook_test
POSTGRES_USER: wrongbook
POSTGRES_PASSWORD: wrongbook
tmpfs:
- /var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "46379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
minio:
image: minio/minio
command: server /data --console-address ":9001"
ports:
- "49000:9000"
- "49001:9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
volumes:
- miniodata:/data
volumes:
pgdata:
miniodata:
View File
+3
View File
@@ -0,0 +1,3 @@
-- 启用扩展
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- 模糊搜索
CREATE EXTENSION IF NOT EXISTS "ltree"; -- 树形路径
View File