chore: 初始化项目结构与登录鉴权模块
后端 FastAPI 六层结构(api/services/repositories/models/schemas/core), 统一响应信封、全局异常、日志脱敏;6 张表经 Alembic 迁移建库,SQL 留存 db/migration;登录鉴权含图形验证码(Redis GETDEL 防重放)、JWT、登录限流、 登出黑名单(TTL=令牌剩余有效期)、登录日志。 前端 Vue3 + TS + Element Plus 骨架,含登录页、路由守卫、请求封装与 Vite 代理。 工程配置:uv 锁定 Python 3.12、ruff 检查与格式化、editorconfig、gitattributes。 测试 184 项(单元 155 / 集成 29)全绿,浏览器端到端 8 项通过。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
commit
273ebf4657
101 files changed
+8725
No files matched your search
@@ -0,0 +1 @@
|
||||
"""横切基础设施:配置、安全、连接、异常、响应、日志。不含业务规则。"""
|
||||
@@ -0,0 +1,160 @@
|
||||
"""图形验证码渲染。
|
||||
|
||||
**为什么自绘而不用 captcha 库的默认渲染**:
|
||||
- 该库默认输出随机彩色字符 + 重噪点 + 弧线,与界面视觉体系严重冲突
|
||||
- 它最后更新于 2025-03,内部还引用了 Pillow 的旧别名(`PIL.ImageDraw.ImageDraw`)
|
||||
|
||||
自绘后配色与界面统一,而识别难度由**逐字旋转 + 垂直抖动 + 干扰曲线 + 噪点**
|
||||
共同保证,强度与原实现相当且可调(见 `_draw_characters` 的注释)。
|
||||
|
||||
仅沿用该库内置的 DroidSansMono 字体文件:自带字体可避开
|
||||
在 Linux 容器里找不到系统字体的经典问题。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import math
|
||||
import random
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from app.core.constants import CAPTCHA_CHARSET, CAPTCHA_LENGTH
|
||||
|
||||
# 画布尺寸与前端展示槽位对齐(登录页为 116×40)
|
||||
_WIDTH, _HEIGHT = 120, 40
|
||||
_FONT_SIZE = 25
|
||||
_PADDING = 8
|
||||
|
||||
# 与前端配色呼应:墨色字符、青灰干扰、素纸底
|
||||
_INK = (15, 54, 70)
|
||||
_INTERFERENCE = (126, 156, 170)
|
||||
_SPECKLE = (168, 183, 192)
|
||||
_PAPER = (247, 246, 242)
|
||||
|
||||
_SPECKLE_COUNT = 46
|
||||
_INTERFERENCE_LINES = 2
|
||||
|
||||
# 备选字体路径:优先用 captcha 包内置字体(跨平台一致),其余为回退
|
||||
_FONT_CANDIDATES = (
|
||||
"C:/Windows/Fonts/consola.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
||||
)
|
||||
|
||||
|
||||
def _font_path() -> Path | None:
|
||||
"""定位可用的等宽字体文件。"""
|
||||
import captcha
|
||||
|
||||
package_file = getattr(captcha, "__file__", None)
|
||||
if package_file:
|
||||
bundled = Path(package_file).parent / "data" / "DroidSansMono.ttf"
|
||||
if bundled.is_file():
|
||||
return bundled
|
||||
|
||||
for candidate in _FONT_CANDIDATES:
|
||||
path = Path(candidate)
|
||||
if path.is_file():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
"""加载字体。找不到任何字体文件时退回 PIL 内置位图字体(仍可用,只是较粗糙)。"""
|
||||
path = _font_path()
|
||||
if path is None:
|
||||
return ImageFont.load_default(size)
|
||||
return ImageFont.truetype(str(path), size)
|
||||
|
||||
|
||||
def generate_text() -> str:
|
||||
"""生成验证码文本。
|
||||
|
||||
用 `secrets` 而非 `random`:验证码是安全凭据,
|
||||
`random` 的 Mersenne Twister 状态可被观测样本反推。
|
||||
"""
|
||||
return "".join(secrets.choice(CAPTCHA_CHARSET) for _ in range(CAPTCHA_LENGTH))
|
||||
|
||||
|
||||
def _draw_speckles(draw: ImageDraw.ImageDraw, count: int) -> None:
|
||||
"""撒噪点。
|
||||
|
||||
用单像素点与 2×2 方块,**不用 `ellipse`** —— 3×3 包围盒画出的椭圆
|
||||
恰好填充中心与四个边中点,形状就是「+」,会形成一种规则的十字纹理,
|
||||
既显眼又容易被整体滤波抹掉,起不到干扰作用。
|
||||
"""
|
||||
for _ in range(count):
|
||||
x = random.randint(0, _WIDTH - 2)
|
||||
y = random.randint(0, _HEIGHT - 2)
|
||||
if random.random() < 0.75:
|
||||
draw.point((x, y), fill=_SPECKLE)
|
||||
else:
|
||||
draw.rectangle((x, y, x + 1, y + 1), fill=_SPECKLE)
|
||||
|
||||
|
||||
def _draw_interference(draw: ImageDraw.ImageDraw) -> None:
|
||||
"""绘制干扰曲线。
|
||||
|
||||
用低对比度的正弦折线而非粗直线:粗直线会把字符主干割断、
|
||||
显著提高正常用户的识错率,而低对比细线对分割算法的干扰并不更差。
|
||||
"""
|
||||
for _ in range(_INTERFERENCE_LINES):
|
||||
amplitude = random.uniform(5.0, 11.0)
|
||||
phase = random.uniform(0, math.tau)
|
||||
base_y = random.uniform(_HEIGHT * 0.25, _HEIGHT * 0.75)
|
||||
points = [
|
||||
(x, base_y + amplitude * math.sin(phase + x / 34.0)) for x in range(0, _WIDTH + 1, 4)
|
||||
]
|
||||
draw.line(points, fill=_INTERFERENCE, width=1)
|
||||
|
||||
|
||||
def _draw_characters(image: Image.Image, text: str) -> None:
|
||||
"""逐字绘制。
|
||||
|
||||
**每个字符单独渲染后旋转再贴回**,而不是整行一起旋转:
|
||||
整行旋转只是把同一个倾斜做一次仿射变换,对齐后即可还原;
|
||||
逐字随机旋转会破坏字符间的版式一致性,模板匹配难以复用。
|
||||
再叠加 ±2px 的垂直抖动,进一步打散基线。
|
||||
"""
|
||||
font = _load_font(_FONT_SIZE)
|
||||
cell = (_WIDTH - 2 * _PADDING) / len(text)
|
||||
|
||||
for index, char in enumerate(text):
|
||||
# 每个字形画在独立画布上以便单独旋转;留出余量避免旋转后切角
|
||||
glyph = Image.new("RGBA", (int(cell) + 16, _HEIGHT), (0, 0, 0, 0))
|
||||
ImageDraw.Draw(glyph).text(
|
||||
(8, 3),
|
||||
char,
|
||||
font=font,
|
||||
fill=(*_INK, 255),
|
||||
# 加 1 像素描边:等宽细体在小字号下笔画过细,
|
||||
# 而本系统的用户是营销人员、屏幕与年龄跨度都大,
|
||||
# 验证码过难会直接变成支持负担。加粗的代价是连通像素略多,
|
||||
# 但相对于"用户反复输错"的代价是划算的。
|
||||
stroke_width=1,
|
||||
stroke_fill=(*_INK, 255),
|
||||
)
|
||||
glyph = glyph.rotate(random.uniform(-14, 14), resample=Image.BICUBIC, expand=False)
|
||||
|
||||
image.paste(glyph, (int(_PADDING + index * cell) - 8, random.randint(-2, 2)), glyph)
|
||||
|
||||
|
||||
def render_png_base64(text: str) -> str:
|
||||
"""把验证码文本渲染为 PNG 并返回 base64(**不含** `data:` 前缀)。
|
||||
|
||||
不含前缀是为了让前端自行拼接;服务端返回纯数据,
|
||||
浏览器 / 小程序 / 桌面端可各自决定呈现方式。
|
||||
"""
|
||||
image = Image.new("RGB", (_WIDTH, _HEIGHT), _PAPER)
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
_draw_speckles(draw, _SPECKLE_COUNT // 2)
|
||||
_draw_interference(draw)
|
||||
_draw_characters(image, text)
|
||||
# 字符之上再撒一层,干扰基于连通域的分割
|
||||
_draw_speckles(draw, _SPECKLE_COUNT // 2)
|
||||
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
return base64.b64encode(buffer.getvalue()).decode("ascii")
|
||||
@@ -0,0 +1,103 @@
|
||||
"""应用配置。
|
||||
|
||||
从 `backend/.env` 读取配置项,字段名与 `.env` 中的变量名一一对应
|
||||
(pydantic-settings 默认大小写不敏感,`app_secret_key` ←→ `APP_SECRET_KEY`)。
|
||||
|
||||
设计取舍:
|
||||
- `APP_SECRET_KEY` 无默认值 —— 缺失时启动即失败。空密钥签发 JWT 属安全问题
|
||||
而非可用性问题,必须 fail-fast,不能容忍"后端起得来但令牌可被伪造"。
|
||||
- 数据库/Redis 连接不在此处建立 —— 依赖不可用时仍应能启动应用、查看接口文档。
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from sqlalchemy import URL
|
||||
|
||||
# backend/ 目录:本文件位于 backend/app/core/config.py,向上三级
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[2]
|
||||
ENV_FILE = BACKEND_DIR / ".env"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""应用配置项。"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=str(ENV_FILE),
|
||||
# Windows 默认编码为 cp936,不显式指定会导致中文值解析出错
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
# .env 中可能含本服务用不到的变量(如 docker 编排专用项),忽略而非报错
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# ===== 应用 =====
|
||||
app_env: str = "dev"
|
||||
app_secret_key: str = Field(description="JWT 签名密钥,至少 32 字符")
|
||||
|
||||
# ===== 鉴权 =====
|
||||
# 规范第 13 节:Token 有效期 ≤ 2 小时
|
||||
jwt_expire_minutes: int = Field(default=120, ge=1, le=120)
|
||||
# 规范第 6 节:验证码 300s
|
||||
captcha_expire_seconds: int = Field(default=300, ge=60, le=300)
|
||||
login_fail_limit: int = Field(default=5, ge=1)
|
||||
login_lock_minutes: int = Field(default=15, ge=1)
|
||||
|
||||
# ===== MySQL =====
|
||||
mysql_host: str = "127.0.0.1"
|
||||
mysql_port: int = 3306
|
||||
mysql_database: str = "payroll"
|
||||
mysql_user: str = "payroll"
|
||||
mysql_password: str = ""
|
||||
|
||||
# ===== Redis =====
|
||||
redis_host: str = "127.0.0.1"
|
||||
redis_port: int = 6379
|
||||
redis_password: str = ""
|
||||
redis_db: int = 0
|
||||
|
||||
@field_validator("app_secret_key")
|
||||
@classmethod
|
||||
def _check_secret_key(cls, v: str) -> str:
|
||||
"""校验密钥强度:过短的密钥可被暴力破解,直接拒绝启动。"""
|
||||
if len(v) < 32:
|
||||
raise ValueError("APP_SECRET_KEY 长度不足 32 字符,请在 backend/.env 中重新生成")
|
||||
return v
|
||||
|
||||
@property
|
||||
def database_url(self) -> URL:
|
||||
"""数据库连接 URL。运行期与 Alembic 迁移共用同一份。
|
||||
|
||||
用 SQLAlchemy 的 URL.create 而非手工拼接字符串 —— 密码含 @ : / 等
|
||||
特殊字符时手工拼接会产生语法错误。
|
||||
|
||||
驱动为同步的 pymysql:本项目并发极低,异步无收益;而绩效核算与
|
||||
pandas 报表都是同步阻塞代码,同步方案不会与之冲突。
|
||||
"""
|
||||
return URL.create(
|
||||
drivername="mysql+pymysql",
|
||||
username=self.mysql_user,
|
||||
password=self.mysql_password,
|
||||
host=self.mysql_host,
|
||||
port=self.mysql_port,
|
||||
database=self.mysql_database,
|
||||
query={"charset": "utf8mb4"},
|
||||
)
|
||||
|
||||
@property
|
||||
def redis_url(self) -> str:
|
||||
"""Redis 连接 URL。密码为空时不拼接认证段。"""
|
||||
auth = f":{self.redis_password}@" if self.redis_password else ""
|
||||
return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""获取配置单例。
|
||||
|
||||
进程内只解析一次 .env;测试中如需覆盖,调用方应使用
|
||||
`get_settings.cache_clear()` 配合环境变量注入。
|
||||
"""
|
||||
return Settings() # type: ignore[call-arg]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""全局常量。
|
||||
|
||||
集中定义 Redis 键模板、响应码、角色编码等跨模块共享的字面量,
|
||||
避免各处硬编码字符串导致拼写漂移。
|
||||
"""
|
||||
|
||||
# ===== 响应码(与 HTTP 状态码保持一致,见 PROJECT_STANDARDS 第 9 节)=====
|
||||
CODE_SUCCESS = 200
|
||||
CODE_BAD_REQUEST = 400
|
||||
CODE_UNAUTHORIZED = 401
|
||||
CODE_FORBIDDEN = 403
|
||||
CODE_NOT_FOUND = 404
|
||||
CODE_SERVER_ERROR = 500
|
||||
|
||||
# ===== Redis 键模板 =====
|
||||
# 规范第 6 节:键格式 {项目缩写}:{业务}:{标识},项目缩写为 ys
|
||||
REDIS_KEY_CAPTCHA = "ys:captcha:{captcha_id}"
|
||||
REDIS_KEY_LOGIN_FAIL = "ys:login:fail:{username}"
|
||||
REDIS_KEY_TOKEN_BLACKLIST = "ys:token:blacklist:{jti}"
|
||||
|
||||
# ===== JWT =====
|
||||
JWT_ALGORITHM = "HS256"
|
||||
TOKEN_TYPE_BEARER = "Bearer"
|
||||
|
||||
# ===== 角色编码(与 db/migration 初始化数据保持一致)=====
|
||||
ROLE_ADMIN = "admin"
|
||||
ROLE_USER = "user"
|
||||
|
||||
# ===== 密码策略 =====
|
||||
# bcrypt 5.0 起对超过 72 字节的密码由「静默截断」改为「抛 ValueError」,
|
||||
# 而中文一个字符占 3 字节,因此必须按字节数校验,按字符数校验是错的。
|
||||
PASSWORD_MIN_BYTES = 8
|
||||
PASSWORD_MAX_BYTES = 72
|
||||
|
||||
# ===== 验证码 =====
|
||||
# 剔除易混淆字符 0/O/1/I/l,降低人工识别错误率
|
||||
CAPTCHA_CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
CAPTCHA_LENGTH = 4
|
||||
@@ -0,0 +1,64 @@
|
||||
"""数据库连接管理。
|
||||
|
||||
**为什么用同步引擎**:本项目并发极低(内部系统,营销人员数十人),
|
||||
异步拿不到吞吐收益;而重活——绩效批量核算、pandas 报表、Excel 导出——
|
||||
全是同步阻塞代码,放在异步路由里会卡死事件循环,且这类问题在开发期
|
||||
低并发下几乎不暴露。改用同步后,路由写 `def`,FastAPI 会**自动放入
|
||||
线程池执行**,天然规避上述陷阱,也不会阻塞事件循环。
|
||||
|
||||
引擎采用**延迟创建**:应用启动时不连接 MySQL,
|
||||
这样 Docker 未启动时仍能运行服务、查看接口文档。
|
||||
"""
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import Engine, create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
_engine: Engine | None = None
|
||||
_session_factory: sessionmaker[Session] | None = None
|
||||
|
||||
|
||||
def get_engine() -> Engine:
|
||||
"""获取同步引擎单例(首次调用时创建)。"""
|
||||
global _engine, _session_factory
|
||||
if _engine is None:
|
||||
settings = get_settings()
|
||||
_engine = create_engine(
|
||||
settings.database_url,
|
||||
# 连接池中的连接在使用前先探活,避免 MySQL 重启后取到死连接
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
echo=False,
|
||||
)
|
||||
_session_factory = sessionmaker(_engine, expire_on_commit=False)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session_factory() -> sessionmaker[Session]:
|
||||
"""获取会话工厂单例。"""
|
||||
get_engine()
|
||||
assert _session_factory is not None # get_engine 已确保初始化
|
||||
return _session_factory
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
"""FastAPI 依赖:提供一个数据库会话。
|
||||
|
||||
只负责会话的创建与关闭,**事务边界由 service 层控制** ——
|
||||
这样一次请求内的多个仓储调用可以共处同一事务。
|
||||
"""
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
def dispose_engine() -> None:
|
||||
"""关闭连接池。应用退出时调用。"""
|
||||
global _engine, _session_factory
|
||||
if _engine is not None:
|
||||
_engine.dispose()
|
||||
_engine = None
|
||||
_session_factory = None
|
||||
@@ -0,0 +1,80 @@
|
||||
"""全局异常处理器。
|
||||
|
||||
把各类异常统一转换为 `{code, message, data}` 响应,
|
||||
避免 FastAPI 默认的 422 `detail` 数组结构泄漏到前端。
|
||||
|
||||
注意:参数校验失败按规范映射为 **400**(FastAPI 默认是 422),
|
||||
因为规范第 9 节只定义了 400/401/403/404/500 五个状态码。
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app.core.constants import CODE_BAD_REQUEST, CODE_SERVER_ERROR
|
||||
from app.core.exceptions import AppException
|
||||
from app.core.response import ApiResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_response(code: int, message: str) -> JSONResponse:
|
||||
"""按统一格式构造 JSON 响应。"""
|
||||
body = ApiResponse[None](code=code, message=message, data=None)
|
||||
return JSONResponse(status_code=code, content=body.model_dump())
|
||||
|
||||
|
||||
def _format_validation_errors(exc: RequestValidationError) -> str:
|
||||
"""把 pydantic 的校验错误压缩成一行中文提示。
|
||||
|
||||
只取首条错误的字段名与说明,避免把完整的错误数组抛给前端。
|
||||
"""
|
||||
errors = exc.errors()
|
||||
if not errors:
|
||||
return "参数错误"
|
||||
first = errors[0]
|
||||
# loc 形如 ("body", "username"),去掉来源前缀只留字段名
|
||||
location = ".".join(
|
||||
str(part) for part in first.get("loc", ()) if part not in ("body", "query", "path")
|
||||
)
|
||||
detail = first.get("msg", "参数错误")
|
||||
return f"参数错误:{location} {detail}" if location else f"参数错误:{detail}"
|
||||
|
||||
|
||||
async def handle_app_exception(_: Request, exc: AppException) -> JSONResponse:
|
||||
"""处理业务异常,按其 code 返回。"""
|
||||
return _build_response(exc.code, exc.message)
|
||||
|
||||
|
||||
async def handle_validation_error(_: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
"""处理请求参数校验失败,映射为 400。"""
|
||||
return _build_response(CODE_BAD_REQUEST, _format_validation_errors(exc))
|
||||
|
||||
|
||||
async def handle_http_exception(_: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||
"""处理框架级 HTTP 异常(404 路由不存在、405 方法不允许等)。"""
|
||||
message = exc.detail if isinstance(exc.detail, str) else "请求处理失败"
|
||||
return _build_response(exc.status_code, message)
|
||||
|
||||
|
||||
async def handle_unexpected_exception(_: Request, exc: Exception) -> JSONResponse:
|
||||
"""兜底处理未预期异常。
|
||||
|
||||
对外只返回通用文案,不泄漏堆栈;完整异常记录到日志供排查。
|
||||
"""
|
||||
logger.exception("未预期的服务端异常")
|
||||
return _build_response(CODE_SERVER_ERROR, "服务端异常,请稍后重试")
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
"""注册全部全局异常处理器。
|
||||
|
||||
注册顺序不影响匹配 —— FastAPI 按异常类型精确查找,越具体的类型优先。
|
||||
"""
|
||||
app.add_exception_handler(AppException, handle_app_exception) # type: ignore[arg-type]
|
||||
app.add_exception_handler(RequestValidationError, handle_validation_error) # type: ignore[arg-type]
|
||||
app.add_exception_handler(StarletteHTTPException, handle_http_exception) # type: ignore[arg-type]
|
||||
app.add_exception_handler(Exception, handle_unexpected_exception)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""业务异常定义。
|
||||
|
||||
`code` 直接作为 HTTP 状态码返回,使前端拦截器既能读 HTTP 状态也能读响应体的 code。
|
||||
|
||||
重要约定:**401 只表示"受保护接口的鉴权失败"**,登录接口自身的失败一律用 400/403。
|
||||
否则前端"401 即跳登录页"的拦截器会在用户输错密码时触发跳转,导致登录页反复重挂载。
|
||||
"""
|
||||
|
||||
from app.core.constants import (
|
||||
CODE_BAD_REQUEST,
|
||||
CODE_FORBIDDEN,
|
||||
CODE_NOT_FOUND,
|
||||
CODE_SERVER_ERROR,
|
||||
CODE_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
|
||||
class AppException(Exception):
|
||||
"""业务异常基类。业务逻辑中主动抛出,由全局处理器转换为统一响应。"""
|
||||
|
||||
code: int = CODE_SERVER_ERROR
|
||||
message: str = "服务端异常"
|
||||
|
||||
def __init__(self, message: str | None = None) -> None:
|
||||
self.message = message or self.message
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class BadRequestError(AppException):
|
||||
"""参数错误或业务规则校验不通过。"""
|
||||
|
||||
code = CODE_BAD_REQUEST
|
||||
message = "参数错误"
|
||||
|
||||
|
||||
class AuthError(AppException):
|
||||
"""未认证:令牌缺失、无效、过期或已登出。"""
|
||||
|
||||
code = CODE_UNAUTHORIZED
|
||||
message = "登录状态已失效,请重新登录"
|
||||
|
||||
|
||||
class ForbiddenError(AppException):
|
||||
"""已认证但无权限,或账号被禁用/锁定。"""
|
||||
|
||||
code = CODE_FORBIDDEN
|
||||
message = "无权限"
|
||||
|
||||
|
||||
class NotFoundError(AppException):
|
||||
"""请求的资源不存在。"""
|
||||
|
||||
code = CODE_NOT_FOUND
|
||||
message = "资源不存在"
|
||||
@@ -0,0 +1,94 @@
|
||||
"""日志配置。
|
||||
|
||||
规范要求(PROJECT_STANDARDS 第 8 节):生产 INFO 级别、按天+大小切分、
|
||||
保留 30 天、禁止输出密码/手机号/身份证/Token。
|
||||
|
||||
Windows 默认编码为 cp936,日志文件 handler 必须显式指定 utf-8,
|
||||
否则中文日志会变成乱码。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
|
||||
from app.core.config import BACKEND_DIR
|
||||
|
||||
LOG_DIR = BACKEND_DIR / "logs"
|
||||
|
||||
# 文本脱敏规则。日志是最容易泄漏敏感信息的地方,且多半来自异常堆栈里的
|
||||
# 请求体字符串,无法靠"调用方注意"来保证,所以在这里统一兜底。
|
||||
_SENSITIVE_RULES: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
# 含 password / passwd / pwd / secret / token 的键名,或 app_key / access_key 之类的短名
|
||||
(
|
||||
re.compile(
|
||||
r'((?:\w*(?:password|passwd|pwd|secret|token)\w*|app_?key|access_?key)["\']?\s*[:=]\s*["\']?)([^"\'\s,}&]+)',
|
||||
re.IGNORECASE,
|
||||
),
|
||||
r"\1***",
|
||||
),
|
||||
# Authorization: Bearer xxx
|
||||
(re.compile(r"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*"), r"\1***"),
|
||||
# 手机号:138****8888
|
||||
(re.compile(r"\b(1[3-9]\d)\d{4}(\d{4})\b"), r"\1****\2"),
|
||||
# 18 位身份证:保留前 6 位与后 4 位
|
||||
(re.compile(r"\b(\d{6})\d{8}(\d{3}[\dXx])\b"), r"\1********\2"),
|
||||
)
|
||||
|
||||
|
||||
def scrub(text: str) -> str:
|
||||
"""对文本按规则脱敏。导出供单测直接验证。"""
|
||||
for pattern, replacement in _SENSITIVE_RULES:
|
||||
text = pattern.sub(replacement, text)
|
||||
return text
|
||||
|
||||
|
||||
class SensitiveFormatter(logging.Formatter):
|
||||
"""脱敏格式化器。
|
||||
|
||||
脱敏必须在父类完成 %-格式化**之后**进行,而不是在 filter 阶段改写 ``record.msg``。
|
||||
后者会把 ``"登录失败 password=%s"`` 里的占位符一并抹成 ``password=***``,
|
||||
随后 logging 内部执行 ``msg % args`` 时因参数无处安放抛 TypeError,
|
||||
导致整条日志丢失 —— 这比不脱敏更危险。
|
||||
"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
return scrub(super().format(record))
|
||||
|
||||
|
||||
def setup_logging(level: str = "INFO", log_to_file: bool = True) -> None:
|
||||
"""初始化根日志配置。
|
||||
|
||||
重复调用是安全的:先清空已有 handler,避免 uvicorn --reload
|
||||
或测试中重复挂载导致日志重复输出。
|
||||
|
||||
:param level: 日志级别
|
||||
:param log_to_file: 是否写文件;测试环境可关闭以避免产生日志文件
|
||||
"""
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level)
|
||||
for handler in list(root.handlers):
|
||||
root.removeHandler(handler)
|
||||
|
||||
formatter = SensitiveFormatter(
|
||||
fmt="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
# 控制台:Windows 终端默认 cp936,中文日志可能报编码错误,显式重配为 utf-8
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr]
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
console.setFormatter(formatter)
|
||||
root.addHandler(console)
|
||||
|
||||
if log_to_file:
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = TimedRotatingFileHandler(
|
||||
filename=LOG_DIR / "app.log",
|
||||
when="midnight",
|
||||
backupCount=30, # 规范要求保留 30 天
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
root.addHandler(file_handler)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Redis 连接管理。
|
||||
|
||||
承担三类职责:图形验证码、登录失败限流、JWT 登出黑名单。
|
||||
键名模板统一在 `app.core.constants` 中定义,禁止在业务代码里拼接字符串。
|
||||
|
||||
与其他基础设施一致采用**同步客户端**:路由写 `def` 由 FastAPI 放入线程池执行。
|
||||
若 DB 同步而 Redis 异步,同一请求里就会出现"要么路由必须写 async(于是同步 DB
|
||||
阻塞事件循环)、要么把 Redis 调用包进 run_in_threadpool"的两难,两头都别扭。
|
||||
"""
|
||||
|
||||
from redis import Redis
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
_client: Redis | None = None
|
||||
|
||||
|
||||
def get_redis() -> Redis:
|
||||
"""获取 Redis 客户端单例(首次调用时创建)。
|
||||
|
||||
延迟创建:Redis 未启动时不影响应用启动。
|
||||
返回的是**同步**客户端,直接调用即可,不要 await。
|
||||
"""
|
||||
global _client
|
||||
if _client is None:
|
||||
settings = get_settings()
|
||||
_client = Redis.from_url(
|
||||
settings.redis_url,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=3,
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
def close_redis() -> None:
|
||||
"""关闭连接池。应用退出时调用。"""
|
||||
global _client
|
||||
if _client is not None:
|
||||
_client.close()
|
||||
_client = None
|
||||
@@ -0,0 +1,62 @@
|
||||
"""统一响应模型。
|
||||
|
||||
所有接口响应体形如 `{code, message, data}`(PROJECT_STANDARDS 第 3 节)。
|
||||
分页响应字段为 `list/total/pageNum/pageSize`(同规范第 14 节)。
|
||||
|
||||
字段名采用驼峰(规范约定)而 Python 属性用蛇形(语言惯例),
|
||||
两者通过 pydantic 的 alias 映射,序列化时输出驼峰。
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.core.constants import CODE_SUCCESS
|
||||
|
||||
|
||||
class ApiResponse[T](BaseModel):
|
||||
"""统一响应包装。泛型参数为 `data` 的类型。"""
|
||||
|
||||
code: int = Field(default=CODE_SUCCESS, description="状态码,与 HTTP 状态码一致")
|
||||
message: str = Field(default="success", description="提示信息")
|
||||
data: T | None = Field(default=None, description="业务数据")
|
||||
|
||||
|
||||
def ok[T](data: T | None = None, message: str = "success") -> ApiResponse[T]:
|
||||
"""构造成功响应。
|
||||
|
||||
统一出口便于日后调整成功码或附加字段时只改一处。
|
||||
"""
|
||||
return ApiResponse(code=CODE_SUCCESS, message=message, data=data)
|
||||
|
||||
|
||||
class PageResult[T](BaseModel):
|
||||
"""分页结果。
|
||||
|
||||
Python 侧使用蛇形属性名,序列化后输出规范要求的驼峰字段名。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
records: list[T] = Field(
|
||||
default_factory=list, alias="list", serialization_alias="list", description="当前页数据"
|
||||
)
|
||||
total: int = Field(default=0, description="总记录数")
|
||||
page_num: int = Field(
|
||||
default=1, alias="pageNum", serialization_alias="pageNum", description="当前页码"
|
||||
)
|
||||
page_size: int = Field(
|
||||
default=10, alias="pageSize", serialization_alias="pageSize", description="每页条数"
|
||||
)
|
||||
|
||||
|
||||
class PageQuery(BaseModel):
|
||||
"""分页查询入参基类。业务查询参数继承本类扩展。"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
page_num: int = Field(default=1, ge=1, alias="pageNum", description="页码,从 1 开始")
|
||||
page_size: int = Field(default=10, ge=1, le=200, alias="pageSize", description="每页条数")
|
||||
|
||||
@property
|
||||
def offset(self) -> int:
|
||||
"""转换为 SQL OFFSET。"""
|
||||
return (self.page_num - 1) * self.page_size
|
||||
@@ -0,0 +1,102 @@
|
||||
"""密码哈希与 JWT 签发校验。
|
||||
|
||||
**为什么不用 passlib**:passlib 1.7.4 自 2020 年起停止维护,且会读取
|
||||
bcrypt 4.1 起已删除的 `bcrypt.__about__` 属性(表现为 "(trapped) error
|
||||
reading bcrypt version"),与 bcrypt 5.x 组合没有任何官方支持。
|
||||
FastAPI 官方文档亦已改用 pwdlib。
|
||||
|
||||
**BCrypt 的 72 字节硬限制**:bcrypt 5.0 起对超过 72 字节的密码**直接抛
|
||||
ValueError**(此前是静默截断)。中文一个字符占 3 字节,24 个汉字即到顶 ——
|
||||
因此必须按字节数校验,按字符数校验是错的。
|
||||
本模块做防御性兜底,调用方(schemas 层)应更早拦截以给出友好提示。
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from pwdlib import PasswordHash
|
||||
from pwdlib.hashers.bcrypt import BcryptHasher
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import JWT_ALGORITHM, PASSWORD_MAX_BYTES
|
||||
from app.core.exceptions import AuthError
|
||||
|
||||
# **显式指定 bcrypt,不用 `PasswordHash.recommended()`**:
|
||||
# 后者在 pwdlib 0.3.x 推荐的是 Argon2,且会随库版本变化 —— 结果是
|
||||
# 同一批密码用不同算法哈希、与规范要求的「密码 BCrypt」不符。
|
||||
# 哈希算法属于会被写入数据、影响长期兼容性的决策,必须钉死。
|
||||
_password_hasher = PasswordHash((BcryptHasher(),))
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
"""计算密码哈希。返回值为 BCrypt 格式,定长 60 字符。
|
||||
|
||||
:raises ValueError: 密码超过 72 字节(bcrypt 算法上限)
|
||||
"""
|
||||
if len(plain.encode("utf-8")) > PASSWORD_MAX_BYTES:
|
||||
raise ValueError(f"密码超过 {PASSWORD_MAX_BYTES} 字节上限,无法哈希")
|
||||
return _password_hasher.hash(plain)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
"""校验密码是否匹配哈希。
|
||||
|
||||
超长密码直接判为不匹配而不抛异常 —— 校验路径不应因输入长度而报错,
|
||||
否则会给攻击者提供区分"密码过长"与"密码错误"的侧信道。
|
||||
"""
|
||||
if len(plain.encode("utf-8")) > PASSWORD_MAX_BYTES:
|
||||
return False
|
||||
try:
|
||||
return _password_hasher.verify(plain, hashed)
|
||||
except Exception:
|
||||
# 哈希串损坏或格式不符时视为校验失败,不向上抛
|
||||
return False
|
||||
|
||||
|
||||
# ==================== JWT ====================
|
||||
|
||||
|
||||
def create_access_token(
|
||||
*, user_id: int, token_version: int, roles: list[str]
|
||||
) -> tuple[str, str, int]:
|
||||
"""签发访问令牌。
|
||||
|
||||
载荷含 `ver`(对应用户的 `token_version`)—— 鉴权时与库中值比对,
|
||||
使「禁用账号 / 改密 / 强制下线」能立即让已签发令牌失效,**不依赖 Redis**。
|
||||
|
||||
:returns: ``(token, jti, 有效期秒数)``。jti 供登出时拉黑使用。
|
||||
"""
|
||||
settings = get_settings()
|
||||
now = datetime.now(UTC)
|
||||
expires_in = settings.jwt_expire_minutes * 60
|
||||
jti = uuid.uuid4().hex
|
||||
|
||||
payload = {
|
||||
"sub": str(user_id), # PyJWT 要求 sub 为字符串
|
||||
"jti": jti,
|
||||
"ver": token_version,
|
||||
"roles": roles, # 仅供前端展示菜单,鉴权时以数据库为准
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=expires_in),
|
||||
}
|
||||
token = jwt.encode(payload, settings.app_secret_key, algorithm=JWT_ALGORITHM)
|
||||
return token, jti, expires_in
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
"""解析并校验令牌的签名与有效期。
|
||||
|
||||
时间一律使用 **UTC 感知时间**:JWT 的 exp 是 UTC 时间戳,若与本地时间
|
||||
(容器 TZ=Asia/Shanghai)比较会出现 8 小时偏差,表现为「刚登录就过期」
|
||||
或「过期了还能用」。
|
||||
|
||||
:raises AuthError: 签名无效、格式错误或已过期
|
||||
"""
|
||||
try:
|
||||
return jwt.decode(token, get_settings().app_secret_key, algorithms=[JWT_ALGORITHM])
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise AuthError("登录已过期,请重新登录") from exc
|
||||
except jwt.PyJWTError as exc:
|
||||
raise AuthError("登录凭证无效,请重新登录") from exc
|
||||
Reference in new issue
Block a user