11
This commit is contained in:
53
.cursor/rules/pythonbackend-layers.mdc
Normal file
53
.cursor/rules/pythonbackend-layers.mdc
Normal file
@@ -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 前端文案风格一致。
|
||||||
142
.cursor/rules/pythonbackend-overview.mdc
Normal file
142
.cursor/rules/pythonbackend-overview.mdc
Normal file
@@ -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`。
|
||||||
19
.env.example
Normal file
19
.env.example
Normal file
@@ -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
|
||||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -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
|
||||||
42
alembic.ini
Normal file
42
alembic.ini
Normal file
@@ -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
|
||||||
63
alembic/env.py
Normal file
63
alembic/env.py
Normal file
@@ -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()
|
||||||
26
alembic/script.py.mako
Normal file
26
alembic/script.py.mako
Normal file
@@ -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"}
|
||||||
45
alembic/versions/001_create_users_table.py
Normal file
45
alembic/versions/001_create_users_table.py
Normal file
@@ -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")
|
||||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""aiclient Python 后端。"""
|
||||||
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/v1/__init__.py
Normal file
0
app/api/v1/__init__.py
Normal file
67
app/api/v1/auth.py
Normal file
67
app/api/v1/auth.py
Normal file
@@ -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),
|
||||||
|
)
|
||||||
6
app/api/v1/router.py
Normal file
6
app/api/v1/router.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.v1 import auth
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
api_router.include_router(auth.router)
|
||||||
47
app/config.py
Normal file
47
app/config.py
Normal file
@@ -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()
|
||||||
0
app/core/__init__.py
Normal file
0
app/core/__init__.py
Normal file
58
app/core/security.py
Normal file
58
app/core/security.py
Normal file
@@ -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}"
|
||||||
38
app/database.py
Normal file
38
app/database.py
Normal file
@@ -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
|
||||||
54
app/dependencies.py
Normal file
54
app/dependencies.py
Normal file
@@ -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)]
|
||||||
44
app/main.py
Normal file
44
app/main.py
Normal file
@@ -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()
|
||||||
3
app/models/__init__.py
Normal file
3
app/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
__all__ = ["User"]
|
||||||
23
app/models/user.py
Normal file
23
app/models/user.py
Normal file
@@ -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(),
|
||||||
|
)
|
||||||
36
app/redis_client.py
Normal file
36
app/redis_client.py
Normal file
@@ -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()
|
||||||
15
app/schemas/__init__.py
Normal file
15
app/schemas/__init__.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
45
app/schemas/auth.py
Normal file
45
app/schemas/auth.py
Normal file
@@ -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
|
||||||
15
app/schemas/common.py
Normal file
15
app/schemas/common.py
Normal file
@@ -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
|
||||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
79
app/services/auth.py
Normal file
79
app/services/auth.py
Normal file
@@ -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)
|
||||||
9
main.py
Normal file
9
main.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import uvicorn
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(
|
||||||
|
"app.main:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=8001,
|
||||||
|
reload=True,
|
||||||
|
)
|
||||||
12
requirements.txt
Normal file
12
requirements.txt
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user