Stage 1: project scaffold with unified middleware and test DB
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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=["学科"])
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)]
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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": "服务器内部错误",
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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_*"]
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 启用扩展
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- 模糊搜索
|
||||
CREATE EXTENSION IF NOT EXISTS "ltree"; -- 树形路径
|
||||
Reference in New Issue
Block a user