From cccd75767be0c4e03b27c5475b104428fce6a732 Mon Sep 17 00:00:00 2001 From: "fengchuanhn@gmail.com" Date: Fri, 15 May 2026 18:52:29 +0800 Subject: [PATCH] 11 --- .cursor/rules/pythonbackend-layers.mdc | 53 ++++++++ .cursor/rules/pythonbackend-overview.mdc | 142 +++++++++++++++++++++ .env.example | 19 +++ .gitignore | 16 +++ alembic.ini | 42 ++++++ alembic/env.py | 63 +++++++++ alembic/script.py.mako | 26 ++++ alembic/versions/001_create_users_table.py | 45 +++++++ app/__init__.py | 1 + app/api/__init__.py | 0 app/api/v1/__init__.py | 0 app/api/v1/auth.py | 67 ++++++++++ app/api/v1/router.py | 6 + app/config.py | 47 +++++++ app/core/__init__.py | 0 app/core/security.py | 58 +++++++++ app/database.py | 38 ++++++ app/dependencies.py | 54 ++++++++ app/main.py | 44 +++++++ app/models/__init__.py | 3 + app/models/user.py | 23 ++++ app/redis_client.py | 36 ++++++ app/schemas/__init__.py | 15 +++ app/schemas/auth.py | 45 +++++++ app/schemas/common.py | 15 +++ app/services/__init__.py | 0 app/services/auth.py | 79 ++++++++++++ main.py | 9 ++ requirements.txt | 12 ++ 29 files changed, 958 insertions(+) create mode 100644 .cursor/rules/pythonbackend-layers.mdc create mode 100644 .cursor/rules/pythonbackend-overview.mdc create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/001_create_users_table.py create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/v1/__init__.py create mode 100644 app/api/v1/auth.py create mode 100644 app/api/v1/router.py create mode 100644 app/config.py create mode 100644 app/core/__init__.py create mode 100644 app/core/security.py create mode 100644 app/database.py create mode 100644 app/dependencies.py create mode 100644 app/main.py create mode 100644 app/models/__init__.py create mode 100644 app/models/user.py create mode 100644 app/redis_client.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/auth.py create mode 100644 app/schemas/common.py create mode 100644 app/services/__init__.py create mode 100644 app/services/auth.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.cursor/rules/pythonbackend-layers.mdc b/.cursor/rules/pythonbackend-layers.mdc new file mode 100644 index 0000000..0bb9b93 --- /dev/null +++ b/.cursor/rules/pythonbackend-layers.mdc @@ -0,0 +1,53 @@ +--- +description: app 包内分层编码规范(API / Service / Model / Schema) +globs: app/**/* +alwaysApply: false +--- + +# 分层编码规范 + +编辑 `app/` 下代码时遵循以下约定。 + +## api/v1(路由层) + +- 使用 `APIRouter`,按领域分文件(如 `auth.py`),在 `router.py` 聚合。 +- `response_model=ApiResponse[...]` 声明响应类型。 +- 注入依赖:`DbSession`、`RedisClient`、`CurrentUser`(来自 `app.dependencies`)。 +- 捕获 `services` 中的业务异常(如 `AuthError`),转为 `ApiResponse(ok=False, message=...)`。 +- 不在路由内写 `select()` / `db.add()`;一行调用 service 后包装 `ApiResponse(ok=True, data=...)`。 +- 副作用(写 Redis session)可在路由层于 service 成功后调用,与现有 `auth.py` 一致。 + +## services(业务层) + +- 函数签名显式接收 `AsyncSession` 及业务参数;返回 Pydantic schema 或简单类型。 +- 业务可预期失败:`raise XxxError("中文说明")`,由 API 层捕获。 +- 使用 `sqlalchemy.select` / `session.get`;密码用 `core.security.hash_password` / `verify_password`。 +- 不返回 ORM 给 API 时,用 `UserPublic.model_validate(user)` 等转换。 + +## models(ORM) + +- 继承 `app.database.Base`,`__tablename__` 复数蛇形。 +- 使用 SQLAlchemy 2 `Mapped[]` / `mapped_column`。 +- 在 `app/models/__init__.py` 导出,供 Alembic `env.py` import。 + +## schemas(Pydantic) + +- **请求**:`Field` 约束;与前端对齐的字段用 `alias` + `populate_by_name=True`(如 `confirmPassword`)。 +- **响应**:`model_config = ConfigDict(from_attributes=True)` 以便从 ORM 转换。 +- **信封**:所有对外业务 JSON 经 `ApiResponse[T]`,勿在路由返回裸 dict(`/health` 除外仍用 ApiResponse)。 + +## core + +- 仅放无状态的纯函数/工具(JWT、密码、常量前缀)。 +- 不依赖 FastAPI、Session。 + +## dependencies + +- 集中定义 `Annotated[..., Depends(...)]` 别名。 +- 鉴权逻辑放在 `get_current_user`:先 Redis session,再 JWT `sub` 查库。 + +## 命名与风格 + +- 文件名:领域名蛇形(`auth.py`)。 +- API 路径:小写、复数资源或动词路径(`/auth/login`)。 +- 用户可见 `message` 使用简体中文,与 aiclient 前端文案风格一致。 diff --git a/.cursor/rules/pythonbackend-overview.mdc b/.cursor/rules/pythonbackend-overview.mdc new file mode 100644 index 0000000..094c19f --- /dev/null +++ b/.cursor/rules/pythonbackend-overview.mdc @@ -0,0 +1,142 @@ +--- +description: pythonbackend 项目总览、分层架构与运行方式 +alwaysApply: true +--- + +# pythonbackend 项目约定 + +aiclient 的 Python 后端,为桌面/前端提供 HTTP API。默认监听 `http://127.0.0.1:8001`(见根目录 `main.py`)。 + +## 技术栈 + +| 组件 | 库 | 用途 | +|------|-----|------| +| Web | FastAPI + Uvicorn | 异步 HTTP、OpenAPI(`/docs`) | +| 校验/配置 | Pydantic v2、`pydantic-settings` | 请求/响应模型、`.env` 配置 | +| 数据库 | SQLAlchemy 2(async)+ **aiomysql** | MySQL 异步访问 | +| 迁移 | Alembic | 表结构版本管理(异步 `alembic/env.py`) | +| 缓存/会话 | **redis**(`redis.asyncio`) | JWT 会话存证、登出吊销 | +| 安全 | passlib(bcrypt)、python-jose | 密码哈希、JWT 签发/校验 | + +## 目录结构 + +``` +pythonbackend/ +├── main.py # 开发入口:uvicorn 启动 app.main:app +├── requirements.txt +├── .env / .env.example # 环境变量(不入库 .env) +├── alembic.ini +├── alembic/ +│ ├── env.py # 从 app.config 读 database_url,异步迁移 +│ └── versions/ # 迁移脚本 +└── app/ + ├── main.py # FastAPI 工厂、CORS、lifespan、路由挂载 + ├── config.py # Settings(get_settings 单例) + ├── database.py # AsyncEngine、Session、Base、get_db + ├── redis_client.py # init/close/get_redis、redis_dependency + ├── dependencies.py # FastAPI Depends(DbSession、CurrentUser 等) + ├── core/ # 与框架无关的工具(security) + ├── models/ # SQLAlchemy ORM 模型 + ├── schemas/ # Pydantic 入参/出参、ApiResponse + ├── services/ # 业务逻辑(不依赖 Request/Response) + └── api/v1/ # 路由层:薄控制器,组装 ApiResponse +``` + +## 分层架构(自上而下) + +``` +HTTP 请求 + → api/v1/*.py 路由:解析 body、注入 Depends、捕获业务异常 → ApiResponse + → services/*.py 业务:查询/写入 DB、抛 AuthError 等,返回 schema 或模型 + → models/*.py ORM:表定义,仅在 services / dependencies 使用 + → database.py 连接池与 get_db(请求结束 commit,异常 rollback) + +横切: + → schemas/ 请求体验证、响应类型;对外 JSON 字段 snake_case(注册 confirmPassword 例外见 auth schema) + → core/security.py 密码哈希、JWT、Redis session key 前缀 + → dependencies.py 鉴权:Redis session 优先,否则回退 JWT sub + → redis_client.py lifespan 内 init_redis,路由经 redis_dependency 注入 +``` + +**原则** + +- `api/` 不写 SQL、不直接操作 Redis 细节;调用 `services/`。 +- `services/` 不 import FastAPI 的 `Request`/`HTTPException`(业务错误用自定义异常,如 `AuthError`)。 +- 新增表:先 `models/` → Alembic 迁移 → 再 `schemas` + `services` + `api`。 + +## 配置(app/config.py) + +- 从项目根 `.env` 加载,字段见 `.env.example`。 +- `database_url`:`mysql+aiomysql://...` +- `redis_url`、`secret_key`、`access_token_expire_minutes`、`cors_origins`(逗号分隔或 `*`)。 +- 通过 `get_settings()` 获取,勿在模块顶层反复实例化 Settings。 + +## 数据库与会话 + +- `get_db()`:每个请求一个 `AsyncSession`,正常结束 `commit`,异常 `rollback`。 +- 路由使用类型别名 `DbSession = Annotated[AsyncSession, Depends(get_db)]`。 +- ORM 基类:`database.Base`;新模型须在 `alembic/env.py` 中 import 以纳入 `target_metadata`。 + +## Redis 与会话 + +- 应用 `lifespan`:`init_redis()` → 运行 → `close_redis()`。 +- 登录/注册成功:`services.auth.store_session(redis, token, user_id)`,key `session:{token}`,TTL 与 JWT 过期一致。 +- 登出:`revoke_session` 删除 key;鉴权时 Redis 无记录则回退解析 JWT(已登出会话失效)。 + +## 统一响应 ApiResponse + +定义于 `app/schemas/common.py`: + +- `ok: bool` — 业务是否成功 +- `message: str` — 用户可见提示(中文) +- `data: T | None` — 成功载荷 + +业务失败(如用户名已存在)返回 **HTTP 200** + `ok: false`,勿抛未捕获异常。未认证访问受保护路由由 `dependencies.get_current_user` 抛 **401**(非 ApiResponse 信封)。 + +前端约定详见 aiclient 仓库 `.cursor/rules/aiclient-overview.mdc` 中「后端 API 约定」一节。 + +## 认证模块(当前实现) + +| 层 | 文件 | +|----|------| +| 路由 | `app/api/v1/auth.py` | +| 业务 | `app/services/auth.py` | +| 模型 | `app/models/user.py` | +| Schema | `app/schemas/auth.py` | +| 挂载 | `app/api/v1/router.py` → `app/main.py` 前缀 `/api/v1` | + +端点:`POST /auth/register|login|logout`,`GET /auth/me`;根路径 `GET /health`。 + +## Alembic + +```bash +# 项目根目录,激活 venv 后 +alembic upgrade head # 应用迁移 +alembic revision -m "描述" # 新建迁移(需人工编写 upgrade/downgrade) +``` + +- `alembic/env.py` 使用异步引擎,URL 来自 `Settings.database_url`。 +- 改表后必须提交 `alembic/versions/` 下的迁移文件。 + +## 常用命令 + +```bash +alembic upgrade head +python main.py # 或: uvicorn app.main:app --reload --port 8001 +``` + +## 新增功能 checklist + +1. `app/models/` 定义 ORM(如需持久化) +2. `alembic revision` + `upgrade head` +3. `app/schemas/` 请求/响应模型 +4. `app/services/` 业务函数 +5. `app/api/v1/` 路由,挂到 `router.py` +6. 需登录:`dependencies.CurrentUser`;需 Redis:参数 `RedisClient` +7. 对外 JSON 保持 `ApiResponse` 信封;错误文案简体中文 + +## 通用原则 + +- Python 3.11+ 类型注解;异步路由与 async service 一致。 +- 密码仅存 `password_hash`,禁止日志或响应中输出明文密码。 +- 生产环境必须修改 `SECRET_KEY`,勿提交 `.env`。 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..237b51c --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +APP_NAME=aiclient-api +DEBUG=true + +# MySQL(aiomysql) +MYSQL_HOST=127.0.0.1 +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD= +MYSQL_DATABASE=aiclient + +# Redis +REDIS_URL=redis://127.0.0.1:6379/0 + +# JWT +SECRET_KEY=change-me-to-a-long-random-string +ACCESS_TOKEN_EXPIRE_MINUTES=10080 + +# CORS(逗号分隔,* 表示允许全部) +CORS_ORIGINS=http://localhost:1420,tauri://localhost diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e61bcd --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +__pycache__/ +*.py[cod] +*$py.class +.Python +.venv/ +venv/ +env/ +.env +*.egg-info/ +dist/ +build/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +htmlcov/ +.coverage diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..cd64a16 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,42 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os + +sqlalchemy.url = driver://user:pass@localhost/dbname + +[post_write_hooks] + +[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 diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..56edc0e --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,63 @@ +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from app.config import get_settings +from app.database import Base +from app.models import User # noqa: F401 — 注册元数据 + +config = context.config +settings = get_settings() + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata +config.set_main_option("sqlalchemy.url", settings.database_url) + + +def run_migrations_offline() -> None: + 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: + 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: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${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 identifiers, used by Alembic. +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"} diff --git a/alembic/versions/001_create_users_table.py b/alembic/versions/001_create_users_table.py new file mode 100644 index 0000000..6f50752 --- /dev/null +++ b/alembic/versions/001_create_users_table.py @@ -0,0 +1,45 @@ +"""create users table + +Revision ID: 001 +Revises: +Create Date: 2026-05-15 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("username", sa.String(length=64), nullable=False), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True) + + +def downgrade() -> None: + op.drop_index(op.f("ix_users_username"), table_name="users") + op.drop_table("users") diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..ccbee1c --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""aiclient Python 后端。""" diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py new file mode 100644 index 0000000..cc3e5d6 --- /dev/null +++ b/app/api/v1/auth.py @@ -0,0 +1,67 @@ +from fastapi import APIRouter, Header + +from app.dependencies import CurrentUser, DbSession, RedisClient +from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserPublic +from app.schemas.common import ApiResponse +from app.services import auth as auth_service + +router = APIRouter(prefix="/auth", tags=["认证"]) + + +@router.post("/register", response_model=ApiResponse[TokenResponse]) +async def register( + body: RegisterRequest, + db: DbSession, + redis: RedisClient, +) -> ApiResponse[TokenResponse]: + try: + result = await auth_service.register_user( + db, + username=body.username, + password=body.password, + ) + except auth_service.AuthError as exc: + return ApiResponse(ok=False, message=exc.message) + + await auth_service.store_session(redis, result.access_token, result.user.id) + return ApiResponse(ok=True, message="注册成功", data=result) + + +@router.post("/login", response_model=ApiResponse[TokenResponse]) +async def login( + body: LoginRequest, + db: DbSession, + redis: RedisClient, +) -> ApiResponse[TokenResponse]: + try: + result = await auth_service.login_user( + db, + username=body.username, + password=body.password, + ) + except auth_service.AuthError as exc: + return ApiResponse(ok=False, message=exc.message) + + await auth_service.store_session(redis, result.access_token, result.user.id) + return ApiResponse(ok=True, message="登录成功", data=result) + + +@router.post("/logout", response_model=ApiResponse[None]) +async def logout( + redis: RedisClient, + authorization: str | None = Header(default=None), +) -> ApiResponse[None]: + if authorization and authorization.lower().startswith("bearer "): + token = authorization[7:].strip() + if token: + await auth_service.revoke_session(redis, token) + return ApiResponse(ok=True, message="已退出登录") + + +@router.get("/me", response_model=ApiResponse[UserPublic]) +async def me(current_user: CurrentUser) -> ApiResponse[UserPublic]: + return ApiResponse( + ok=True, + message="", + data=UserPublic.model_validate(current_user), + ) diff --git a/app/api/v1/router.py b/app/api/v1/router.py new file mode 100644 index 0000000..68daf1e --- /dev/null +++ b/app/api/v1/router.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter + +from app.api.v1 import auth + +api_router = APIRouter() +api_router.include_router(auth.router) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..b8b56e7 --- /dev/null +++ b/app/config.py @@ -0,0 +1,47 @@ +from functools import lru_cache + +from pydantic import Field +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 = "aiclient-api" + debug: bool = False + + mysql_host: str = "127.0.0.1" + mysql_port: int = 3306 + mysql_user: str = "root" + mysql_password: str = "" + mysql_database: str = "aiclient" + + redis_url: str = "redis://127.0.0.1:6379/0" + + secret_key: str = Field(default="change-me-in-production") + access_token_expire_minutes: int = 60 * 24 * 7 + + cors_origins: str = "http://localhost:1420" + + @property + def database_url(self) -> str: + return ( + f"mysql+aiomysql://{self.mysql_user}:{self.mysql_password}" + f"@{self.mysql_host}:{self.mysql_port}/{self.mysql_database}" + ) + + @property + def cors_origin_list(self) -> list[str]: + raw = self.cors_origins.strip() + if raw == "*": + return ["*"] + return [o.strip() for o in raw.split(",") if o.strip()] + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..45b8212 --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,58 @@ +from datetime import UTC, datetime, timedelta +from typing import Any + +import bcrypt +from jose import JWTError, jwt + +from app.config import get_settings + +settings = get_settings() + +ALGORITHM = "HS256" +TOKEN_TYPE = "access" +SESSION_PREFIX = "session:" + + +def hash_password(password: str) -> str: + return bcrypt.hashpw( + password.encode("utf-8"), + bcrypt.gensalt(), + ).decode("utf-8") + + +def verify_password(plain: str, hashed: str) -> bool: + try: + return bcrypt.checkpw( + plain.encode("utf-8"), + hashed.encode("utf-8"), + ) + except (ValueError, TypeError): + return False + + +def create_access_token(subject: str, expires_delta: timedelta | None = None) -> str: + expire = datetime.now(UTC) + ( + expires_delta + if expires_delta is not None + else timedelta(minutes=settings.access_token_expire_minutes) + ) + payload = { + "sub": subject, + "exp": expire, + "type": TOKEN_TYPE, + } + return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM) + + +def decode_access_token(token: str) -> dict[str, Any] | None: + try: + payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM]) + except JWTError: + return None + if payload.get("type") != TOKEN_TYPE: + return None + return payload + + +def session_redis_key(token: str) -> str: + return f"{SESSION_PREFIX}{token}" diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..a3bd767 --- /dev/null +++ b/app/database.py @@ -0,0 +1,38 @@ +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import DeclarativeBase + +from app.config import get_settings + +settings = get_settings() + +engine = create_async_engine( + settings.database_url, + echo=settings.debug, + pool_pre_ping=True, +) + +async_session_factory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +class Base(DeclarativeBase): + pass + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with async_session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..f877d70 --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,54 @@ +from typing import Annotated + +import redis.asyncio as redis +from fastapi import Depends, Header, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import decode_access_token +from app.database import get_db +from app.models.user import User +from app.redis_client import redis_dependency +from app.services import auth as auth_service + +DbSession = Annotated[AsyncSession, Depends(get_db)] +RedisClient = Annotated[redis.Redis, Depends(redis_dependency)] + + +async def get_current_user( + db: DbSession, + redis: RedisClient, + authorization: Annotated[str | None, Header()] = None, +) -> User: + if not authorization or not authorization.lower().startswith("bearer "): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="未提供有效的认证令牌", + ) + + token = authorization[7:].strip() + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="未提供有效的认证令牌", + ) + + user_id = await auth_service.session_user_id(redis, token) + if user_id is None: + payload = decode_access_token(token) + if payload is None or payload.get("sub") is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="令牌无效或已过期", + ) + user_id = int(payload["sub"]) + + user = await auth_service.get_user_by_id(db, user_id) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户不存在", + ) + return user + + +CurrentUser = Annotated[User, Depends(get_current_user)] diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..d2c3a35 --- /dev/null +++ b/app/main.py @@ -0,0 +1,44 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.v1.router import api_router +from app.config import get_settings +from app.redis_client import close_redis, init_redis +from app.schemas.common import ApiResponse + +settings = get_settings() + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + await init_redis() + yield + await close_redis() + + +def create_app() -> FastAPI: + app = FastAPI( + title=settings.app_name, + debug=settings.debug, + lifespan=lifespan, + ) + + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origin_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + @app.get("/health", response_model=ApiResponse[dict]) + async def health() -> ApiResponse[dict]: + return ApiResponse(ok=True, message="服务正常", data={"status": "up"}) + + app.include_router(api_router, prefix="/api/v1") + return app + + +app = create_app() diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..b2e47e8 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,3 @@ +from app.models.user import User + +__all__ = ["User"] diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..646d8d3 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,23 @@ +from datetime import datetime + +from sqlalchemy import DateTime, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + username: Mapped[str] = mapped_column(String(64), unique=True, index=True) + password_hash: Mapped[str] = mapped_column(String(255)) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) diff --git a/app/redis_client.py b/app/redis_client.py new file mode 100644 index 0000000..3b7446d --- /dev/null +++ b/app/redis_client.py @@ -0,0 +1,36 @@ +from collections.abc import AsyncGenerator + +import redis.asyncio as redis + +from app.config import get_settings + +_settings = get_settings() +_redis: redis.Redis | None = None + + +async def init_redis() -> redis.Redis: + global _redis + _redis = redis.from_url( + _settings.redis_url, + encoding="utf-8", + decode_responses=True, + ) + await _redis.ping() + return _redis + + +async def close_redis() -> None: + global _redis + if _redis is not None: + await _redis.aclose() + _redis = None + + +def get_redis() -> redis.Redis: + if _redis is None: + raise RuntimeError("Redis 尚未初始化") + return _redis + + +async def redis_dependency() -> AsyncGenerator[redis.Redis, None]: + yield get_redis() diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..18e7d1e --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,15 @@ +from app.schemas.auth import ( + LoginRequest, + RegisterRequest, + TokenResponse, + UserPublic, +) +from app.schemas.common import ApiResponse + +__all__ = [ + "ApiResponse", + "LoginRequest", + "RegisterRequest", + "TokenResponse", + "UserPublic", +] diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000..1217fe8 --- /dev/null +++ b/app/schemas/auth.py @@ -0,0 +1,45 @@ +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + + +class RegisterRequest(BaseModel): + username: str = Field(min_length=1, max_length=64) + password: str = Field(min_length=6, max_length=128) + confirm_password: str = Field(min_length=6, max_length=128, alias="confirmPassword") + + model_config = ConfigDict(populate_by_name=True) + + @field_validator("username") + @classmethod + def strip_username(cls, value: str) -> str: + return value.strip() + + @model_validator(mode="after") + def passwords_match(self) -> "RegisterRequest": + if self.password != self.confirm_password: + raise ValueError("两次输入的密码不一致") + return self + + +class LoginRequest(BaseModel): + username: str = Field(min_length=1, max_length=64) + password: str = Field(min_length=1, max_length=128) + + model_config = ConfigDict(populate_by_name=True) + + @field_validator("username") + @classmethod + def strip_username(cls, value: str) -> str: + return value.strip() + + +class UserPublic(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + username: str + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user: UserPublic diff --git a/app/schemas/common.py b/app/schemas/common.py new file mode 100644 index 0000000..cbc4222 --- /dev/null +++ b/app/schemas/common.py @@ -0,0 +1,15 @@ +from typing import Generic, TypeVar + +from pydantic import BaseModel, ConfigDict + +T = TypeVar("T") + + +class ApiResponse(BaseModel, Generic[T]): + """与 aiclient 前端约定的统一响应结构。""" + + model_config = ConfigDict(from_attributes=True) + + ok: bool + message: str = "" + data: T | None = None diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/auth.py b/app/services/auth.py new file mode 100644 index 0000000..d5cf53f --- /dev/null +++ b/app/services/auth.py @@ -0,0 +1,79 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.core.security import ( + create_access_token, + hash_password, + session_redis_key, + verify_password, +) +from app.models.user import User +from app.schemas.auth import TokenResponse, UserPublic + +settings = get_settings() + + +class AuthError(Exception): + def __init__(self, message: str) -> None: + self.message = message + super().__init__(message) + + +async def register_user( + db: AsyncSession, + *, + username: str, + password: str, +) -> TokenResponse: + existing = await db.scalar(select(User).where(User.username == username)) + if existing is not None: + raise AuthError("用户名已存在") + + user = User(username=username, password_hash=hash_password(password)) + db.add(user) + await db.flush() + await db.refresh(user) + + token = create_access_token(str(user.id)) + return TokenResponse( + access_token=token, + user=UserPublic.model_validate(user), + ) + + +async def login_user( + db: AsyncSession, + *, + username: str, + password: str, +) -> TokenResponse: + user = await db.scalar(select(User).where(User.username == username)) + if user is None or not verify_password(password, user.password_hash): + raise AuthError("用户名或密码错误") + + token = create_access_token(str(user.id)) + return TokenResponse( + access_token=token, + user=UserPublic.model_validate(user), + ) + + +async def store_session(redis_client, token: str, user_id: int) -> None: + ttl = settings.access_token_expire_minutes * 60 + await redis_client.setex(session_redis_key(token), ttl, str(user_id)) + + +async def revoke_session(redis_client, token: str) -> None: + await redis_client.delete(session_redis_key(token)) + + +async def session_user_id(redis_client, token: str) -> int | None: + raw = await redis_client.get(session_redis_key(token)) + if raw is None: + return None + return int(raw) + + +async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None: + return await db.get(User, user_id) diff --git a/main.py b/main.py new file mode 100644 index 0000000..63c3291 --- /dev/null +++ b/main.py @@ -0,0 +1,9 @@ +import uvicorn + +if __name__ == "__main__": + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=8001, + reload=True, + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..524a845 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +fastapi>=0.115.0,<1.0 +uvicorn[standard]>=0.32.0,<1.0 +pydantic>=2.9.0,<3.0 +pydantic-settings>=2.6.0,<3.0 +sqlalchemy[asyncio]>=2.0.36,<3.0 +aiomysql>=0.2.0,<1.0 +alembic>=1.14.0,<2.0 +redis>=5.2.0,<6.0 +bcrypt>=4.0.0,<6.0 +python-jose[cryptography]>=3.3.0,<4.0 +python-multipart>=0.0.12,<1.0 +greenlet>=3.1.0,<4.0